本章介紹**電影票訂票系統(Movie Ticket Booking System)**的設計。這類題目用來評估你建模真實世界系統與運用 OOP 原則的能力。我們會仔細定義代表電影、影廳與場次等實體的類別,建構出清晰、可運作且可擴充的結構。

Movie Theater

需求蒐集#

題目情境#

想像你想在熱門檔期訂票。登入系統、瀏覽場次、選座、下訂,幾秒後就收到電子票券。背景中,系統管理座位狀態、追蹤場次並計算金額。請設計能流暢處理這一切的訂票系統。

需求釐清對話#

Candidate:系統是否支援跨多家影城(cinema)與多個影廳(room)的搜尋與訂票?

Interviewer:是的,使用者可跨多家影城搜尋。

Candidate:同一部電影是否可以在不同影廳、不同時段排映多場?

Interviewer:可以。

Candidate:同一場次內座位是否有不同的票價層級?

Interviewer:是。座位可有 normal、premium、VIP 等定價策略。

Candidate:使用者能否一次訂多張票?金額怎麼算?

Interviewer:可以。同一場次可合併下單,金額為所有座位票價總和。

Candidate:是否需要處理金流?

Interviewer:本題不討論金流,聚焦在瀏覽、排映、選座、訂票即可。

Candidate:訂票時系統的行為?

Interviewer:建立一張包含 screening、seat 與計算後票價的 ticket,加入該場次的票券清單,並標記座位為已訂。

需求整理#

功能性需求#

電影與場次管理

  • 每家影城位於特定地點,包含多個影廳
  • 每部電影可在不同影廳、影城、時段排映多場

座位與票價

  • 每個影廳有座位網格供訂票
  • 座位可設不同定價策略(normal、premium、VIP)

搜尋與訂票流程

  • 使用者可搜尋並訂購可用票券
  • Ticket 代表「在某時段、某影廳觀看某電影的某座位」
  • 一張訂單可包含多張票券,總價為所有座位票價之和

非功能性需求#

  • 場次搜尋必須快速以提供順暢體驗
  • 基本的錯誤處理需防止重複訂位(double booking)

辨識核心物件#

  • Movie:電影本身,包含 title、duration 等靜態資訊
  • Cinema:實體影城,包含多個 Room
  • Room:影城中的單一影廳,連結獨特的座位配置
  • Layout:以網格組織 Room 內的座位
  • Seat:單一座位,連結定價策略
  • Screening:結合 Movie、Room、時段,定義一場具體放映
  • Ticket:代表使用者購買的座位 + 場次組合,含購買時的票價
  • Order:將多張一起購買的 Ticket 彙整為單一交易

Design Choice:將 Movie 與 Screening 分離,可區分電影靜態資料與動態排程,提升重用性與清晰度。將 Room 與 Layout 分離,則讓多個影廳能共享或客製化座位配置。

面試技巧:呈現核心物件時,務必說明選擇理由與如何符合需求,並提及替代方案(例如合併類別)以展現你已考慮過取捨。

類別圖設計#

Movie#

Movie 類別封裝靜態資訊——title、genre、duration——這些屬性在所有場次間保持一致。它與 Screening 不同,後者把電影綁定到特定影廳與時段。

Movie class diagram

Design Choice:Movie 設計為獨立實體,與影城或排程脈絡無關,因此同一部 Movie 能被不同影城與場次重用而不需重複資料。

Seat#

Seat 類別封裝座位編號與定價邏輯,運用 Strategy Pattern 透過 PricingStrategy 介面(具體類別 NormalRate、PremiumRate、VIPRate)彈性管理價格。

Strategy Pattern 帶來兩大好處:

  • 可擴充性:輕鬆新增新的票價類別
  • 減少冗餘:以單一 Seat 類別處理所有定價變化

Seat and PricingStrategy Design

替代方案:將定價邏輯直接內嵌在 Seat 中能簡化結構,但定價規則變動時彈性差。Strategy Pattern 雖較複雜,但支援未來擴充。

想了解更多 Strategy Pattern,請參考 Parking Lot 章節。

Layout#

Layout 是個別 Seat 與 Room 之間的橋樑,以網格組織座位。它使用巢狀 map(Map<Integer, Map<Integer, Seat>>):

  • 外層 map 的 key 是列號(row)
  • 內層 map 的 key 是欄號(column)
  • 值是該位置的 Seat 物件

同時維護一個依座位編號查找的索引(Map<String, Seat>),快速以編號定位座位。

Layout class diagram

替代方案:Layout 使用巢狀 map 而非 2D array,是為了支援動態建立列(透過 computeIfAbsent)與不規則大小。2D array 適用於固定大小,但缺乏彈性。

面試技巧:說明資料結構選擇(map vs. array)的理由,以及它如何支撐你的設計目標。

Cinema 與 Room#

Cinema 與 Room 類別建構整個影城結構。一家 Cinema 包含多個 Room(組合關係),每個 Room 透過 Layout 定義座位排列。

Cinema and Room classes

Design Choice:以組合(composition)關係模擬真實的多廳影城。每個 Room 可獨立運作其布局與排程,Cinema 則提供統一脈絡。

Screening#

Screening 將 Movie、Room 與時間組合為單一實體,定義「在哪個影廳、何時放映何片」。

Screening class diagram

Design Choice:Screening 集中管理排程細節,讓跨影城的場次管理更容易,並維持清晰的職責分離。

Ticket#

Ticket 代表為某 Screening 購買的座位,結合一個 Screening 與一個 Seat。

它包含 price 屬性,紀錄購買當下的價格。即使 Seat 的 PricingStrategy 之後改變,Ticket 的價格仍保持固定。

Ticket class diagram

Order#

Order 將多張票券彙整為單一交易,記錄下單時間並計算總額。

Order class diagram

ScreeningManager#

僅有 Movie、Seat、Ticket 等基礎類別不足以構成完整系統。我們設計 ScreeningManager 來管理:

  • 搜尋特定電影的場次
  • 識別可用座位
  • 儲存已售出票券

ScreeningManager class diagram

Design Choice:替代方案是把這些操作放入 Cinema 類別,讓每家影城自管場次與票券。但這會把靜態的影城屬性與排程/訂票邏輯耦合在一起,降低模組化與可維護性。

MovieBookingSystem#

MovieBookingSystem 是把所有元件整合在一起的最後一塊拼圖。它整合電影清單、影城清單與 ScreeningManager,作為 facade 提供統一入口:

  • 新增電影或影城
  • 搜尋電影的場次
  • 查詢可用座位
  • 訂購票券

MovieBookingSystem class diagram

替代方案:讓客戶端直接與 ScreeningManager 或 Cinema 互動。但這會增加耦合並讓互動變脆弱。Facade 模式提升維護性並簡化系統互動。

完整類別圖#

Class Diagram of Movie Ticket Booking System

程式實作#

Movie#

Movie 類別不可變(immutable),沒有 setter 方法,確保建立後資料不被修改。

public class Movie {
    private final String title;
    private final String genre;
    private final int durationInMinutes;

    public Movie(String title, String genre, int durationInMinutes) {
        this.title = title;
        this.genre = genre;
        this.durationInMinutes = durationInMinutes;
    }

    public Duration getDuration() {
        return Duration.ofMinutes(durationInMinutes);
    }

    // getter methods are omitted for brevity
}

getDuration()durationInMinutes 轉為 Duration 物件,提供標準化的時長表示。

Cinema#

public class Cinema {
    private final String name;
    private final String location;
    private final List<Room> rooms;

    public Cinema(String name, String location) {
        this.name = name;
        this.location = location;
        this.rooms = new ArrayList<>();
    }

    public void addRoom(Room room) {
        rooms.add(room);
    }
    // getter and setter methods are omitted for brevity
}

Room#

public class Room {
    private final String roomNumber;
    private final Layout layout;

    public Room(String roomNumber, Layout layout) {
        this.roomNumber = roomNumber;
        this.layout = layout;
    }
    // getter and setter methods are omitted for brevity
}

Layout#

// Represents the seating layout of a cinema room.
public class Layout {
    private final int rows;
    private final int columns;

    // Maps seat numbers (e.g., "0-0") to Seat objects for direct access
    private final Map<String, Seat> seatsByNumber;

    // Nested map for position-based access (row -> column -> seat)
    private final Map<Integer, Map<Integer, Seat>> seatsByPosition;

    public Layout(int rows, int columns) {
        this.rows = rows;
        this.columns = columns;
        this.seatsByNumber = new HashMap<>();
        this.seatsByPosition = new HashMap<>();
        initializeLayout();
    }

    // Creates seats for all positions with default null pricing
    private void initializeLayout() {
        for (int i = 0; i < rows; i++) {
            for (int j = 0; j < columns; j++) {
                String seatNumber = i + "-" + j;
                addSeat(seatNumber, i, j, new Seat(seatNumber, null));
            }
        }
    }

    public void addSeat(String seatNumber, int row, int column, Seat seat) {
        // Store seat in number-based lookup map
        seatsByNumber.put(seatNumber, seat);
        // Store seat in position-based lookup map
        seatsByPosition.computeIfAbsent(row, k -> new HashMap<>()).put(column, seat);
    }

    public Seat getSeatByNumber(String seatNumber) {
        return seatsByNumber.get(seatNumber);
    }

    // Gets a seat by its row and column position
    public Seat getSeatByPosition(int row, int column) {
        Map<Integer, Seat> rowSeats = seatsByPosition.get(row);
        return (rowSeats != null) ? rowSeats.get(column) : null;
    }

    public List<Seat> getAllSeats() {
        return List.copyOf(seatsByNumber.values());
    }
}

關鍵方法:

  • addSeat:透過 computeIfAbsent 動態建立新列
  • getSeatByNumber:以唯一字串 ID 高效查找座位
  • getSeatByPosition:以列欄座標精確存取
  • getAllSeats:回傳不可修改的座位清單

Implementation Choice:巢狀 Map<Integer, Map<Integer, Seat>>Map<String, Seat> 都提供 O(1) 存取,並支援動態擴充。替代方案 Seat[][] 適合固定大小,但缺乏彈性。

Seat#

public class Seat {
    private final String seatNumber;
    private PricingStrategy pricingStrategy;

    public Seat(String seatNumber, PricingStrategy pricingStrategy) {
        this.seatNumber = seatNumber;
        this.pricingStrategy = pricingStrategy;
    }
    // getter and setter methods are omitted for brevity
}

替代方案:直接在 Seat 中以 enum(NORMAL、PREMIUM)儲存類型與價格。但這會讓定價邏輯緊耦合於 Seat,難以擴充新規則。

PricingStrategy#

PricingStrategy 介面要求實作 getPrice()。具體類別封裝固定價格,遵循開放/封閉原則——可新增策略而不修改既有程式碼。

public interface PricingStrategy {
    BigDecimal getPrice();
}

public class NormalRate implements PricingStrategy {
    private final BigDecimal price;

    public NormalRate(BigDecimal price) {
        this.price = price;
    }

    @Override
    public BigDecimal getPrice() {
        return price;
    }
}

public class PremiumRate implements PricingStrategy {
    private final BigDecimal price;

    public PremiumRate(BigDecimal price) {
        this.price = price;
    }

    @Override
    public BigDecimal getPrice() {
        return price;
    }
}

public class VIPRate implements PricingStrategy {
    private final BigDecimal price;

    public VIPRate(BigDecimal price) {
        this.price = price;
    }

    @Override
    public BigDecimal getPrice() {
        return price;
    }
}

Screening#

// Represents a scheduled screening of a movie in a specific cinema room.
public class Screening {
    private final Movie movie;
    private final Room room;
    private final LocalDateTime startTime;
    private final LocalDateTime endTime;

    public Screening(Movie movie, Room room, LocalDateTime startTime, LocalDateTime endTime) {
        this.movie = movie;
        this.room = room;
        this.startTime = startTime;
        this.endTime = endTime;
    }

    public Duration getDuration() {
        return Duration.between(startTime, endTime);
    } // getter and setter methods are omitted for brevity
}

Ticket#

public class Ticket {
    private final Screening screening;
    private final Seat seat;
    private final BigDecimal price;

    public Ticket(Screening screening, Seat seat, BigDecimal price) {
        this.screening = screening;
        this.seat = seat;
        this.price = price;
    }

    // getter and setter methods are omitted for brevity
}

Order#

public class Order {
    private final List<Ticket> tickets;
    private final LocalDateTime orderDate;

    public Order(LocalDateTime orderDate) {
        this.tickets = new ArrayList<>();
        this.orderDate = orderDate;
    }

    public void addTicket(Ticket ticket) {
        tickets.add(ticket);
    }

    // Calculates the total price of all tickets in the order
    public BigDecimal calculateTotalPrice() {
        return tickets.stream().map(Ticket::getPrice).reduce(BigDecimal.ZERO, BigDecimal::add);
    }

    // getter and setter methods are omitted for brevity
}

ScreeningManager#

// Manages the relationships between movies, screenings, and tickets in the booking system
public class ScreeningManager {
    // Maps movies to their scheduled screenings
    private final Map<Movie, List<Screening>> screeningsByMovie;
    // Maps screenings to tickets sold for that screening
    private final Map<Screening, List<Ticket>> ticketsByScreening;

    public ScreeningManager() {
        this.screeningsByMovie = new HashMap<>();
        this.ticketsByScreening = new HashMap<>();
    }

    public void addScreening(Movie movie, Screening screening) {
        screeningsByMovie.computeIfAbsent(movie, k -> new ArrayList<>()).add(screening);
    }

    // Returns all screenings for a specific movie
    public List<Screening> getScreeningsForMovie(Movie movie) {
        return screeningsByMovie.getOrDefault(movie, new ArrayList<>());
    }

    public void addTicket(Screening screening, Ticket ticket) {
        ticketsByScreening.computeIfAbsent(screening, k -> new ArrayList<>()).add(ticket);
    }

    // Returns all tickets sold for a specific screening
    public List<Ticket> getTicketsForScreening(Screening screening) {
        return ticketsByScreening.getOrDefault(screening, new ArrayList<>());
    }

    // Calculates which seats are still available for a screening
    public List<Seat> getAvailableSeats(Screening screening) {
        List<Seat> allSeats = screening.getRoom().getLayout().getAllSeats();
        List<Ticket> bookedTickets = getTicketsForScreening(screening);

        List<Seat> availableSeats = new ArrayList<>(allSeats);
        for (Ticket ticket : bookedTickets) {
            availableSeats.remove(ticket.getSeat());
        }
        return availableSeats;
    }
}
  • addScreeningaddTicketcomputeIfAbsent 動態初始化清單
  • getAvailableSeats 從 Layout 取得全部座位後扣除已售出的,回傳剩餘可用座位

Implementation Choice:使用 Map<Movie, List<Screening>>Map<Screening, List<Ticket>> 提供 O(1) 查找。替代方案是用 List 加手動過濾,但會變成 O(n)。

MovieBookingSystem#

// Manages the complete movie booking system operations
public class MovieBookingSystem {

    private final List<Movie> movies;
    private final List<Cinema> cinemas;
    private final ScreeningManager screeningManager;

    public MovieBookingSystem() {
        this.movies = new ArrayList<>();
        this.cinemas = new ArrayList<>();
        this.screeningManager = new ScreeningManager();
    }

    public void addMovie(Movie movie) {
        movies.add(movie);
    }

    public void addCinema(Cinema cinema) {
        cinemas.add(cinema);
    }

    public void addScreening(Movie movie, Screening screening) {
        screeningManager.addScreening(movie, screening);
    }

    // Books a ticket for a specific seat at a screening
    public void bookTicket(Screening screening, Seat seat) {
        BigDecimal price = seat.getPricingStrategy().getPrice();
        Ticket ticket = new Ticket(screening, seat, price);
        screeningManager.addTicket(screening, ticket);
    }

    // Returns all screenings for a specific movie
    public List<Screening> getScreeningsForMovie(Movie movie) {
        return screeningManager.getScreeningsForMovie(movie);
    }

    // Returns all available seats for a screening
    public List<Seat> getAvailableSeats(Screening screening) {
        return screeningManager.getAvailableSeats(screening);
    }

    // Returns the number of tickets sold for a screening
    public int getTicketCount(Screening screening) {
        return screeningManager.getTicketsForScreening(screening).size();
    }

    // Returns the list of tickets for a screening
    public List<Ticket> getTicketsForScreening(Screening screening) {
        return screeningManager.getTicketsForScreening(screening);
    } // getter and setter methods are omitted for brevity
}

bookTicket 動態取得座位的 PricingStrategy 計算票價,建立 Ticket 並交由 ScreeningManager 儲存。

深度討論#

處理並行訂票(Concurrent Bookings)#

OOD 面試中,並行(concurrency)常被用來討論訂票系統等多人同時使用的情境。

問題:Race Condition#

兩位使用者同時嘗試訂同一座位,可能都收到確認,造成重複訂位(double-booking),破壞系統可靠性。

解法:悲觀鎖與樂觀鎖#

可使用鎖機制將平行操作序列化。常見兩種:

悲觀鎖(Pessimistic Locking)

  • 訂票流程一開始就取得獨佔鎖
  • 在鎖釋放前阻擋其他使用者
  • 整個交易期間持有鎖,並設逾時機制(如 30 秒)自動釋放
  • 適用:高度競爭情境,多人頻繁搶同一座位
  • 缺點:取鎖與釋鎖造成延遲

樂觀鎖(Optimistic Locking)

  • 訂票過程中不鎖定座位
  • 在交易最後階段才驗證座位是否仍可用
  • 若已被別人訂走則交易失敗,使用者需重試
  • 仰賴原子操作(如 synchronized 或 DB transaction)確保一致性
  • 適用:低度競爭情境
  • 缺點:高度競爭時頻繁衝突會導致大量重試

實作:悲觀鎖#

引入 SeatLockManager 類別,使用 Java 並行特性管理暫時鎖。以 ConcurrentHashMap 確保執行緒安全,並用 synchronized 防止建立/清理時的競態。

public class SeatLockManager {
    private final Map<String, SeatLock> lockedSeats = new ConcurrentHashMap<>();
    private final Duration lockDuration;

    public SeatLockManager(Duration lockDuration) {
        this.lockDuration = lockDuration;
    }

    public synchronized boolean lockSeat(Screening screening, Seat seat, String userId) {
        String lockKey = generateLockKey(screening, seat);

        // Clean up lock if expired (on-demand cleanup when another process attempts to lock)
        cleanupLockIfExpired(lockKey);
        // Check if a seat is already locked
        if (isLocked(screening, seat)) {
            return false;
        }

        // Create a new lock with expiration time
        SeatLock lock = new SeatLock(userId, LocalDateTime.now().plus(lockDuration));
        lockedSeats.put(lockKey, lock);
        return true;
    }

    public synchronized boolean isLocked(Screening screening, Seat seat) {
        String lockKey = generateLockKey(screening, seat);

        // Clean up lock if expired (on-demand cleanup)
        cleanupLockIfExpired(lockKey);

        // If we reach here, either no lock exists or it's valid
        return lockedSeats.containsKey(lockKey);
    }

    private void cleanupLockIfExpired(String lockKey) {
        SeatLock lock = lockedSeats.get(lockKey);
        if (lock != null && lock.isExpired()) {
            lockedSeats.remove(lockKey);
        }
    }

    private String generateLockKey(Screening screening, Seat seat) {
        return screening.getId() + "-" + seat.getSeatNumber();
    }

    // SeatLock inner class
    private static class SeatLock {
        private final String userId;
        private final LocalDateTime expirationTime;

        public SeatLock(String userId, LocalDateTime expirationTime) {
            this.userId = userId;
            this.expirationTime = expirationTime;
        }

        public boolean isExpired() {
            return LocalDateTime.now().isAfter(expirationTime);
        }

        public String getUserId() {
            return userId;
        }
    }
}

實作:樂觀鎖#

更新 ScreeningManager,在訂票最後一刻才檢查座位狀態,無需持久鎖。synchronized 確保檢查與訂票原子執行。

// Simplified optimistic locking in ScreeningManager
public synchronized Ticket bookSeatOptimistically(Screening screening, Seat seat) {
    // First check if a seat is available (optimistic)
    if (isSeatBooked(screening, seat)) {
        throw new IllegalStateException("Seat is already booked");
    }

    // Create ticket - at this point, we're optimistically assuming
    // the seat is still available
    BigDecimal price = seat.getPricingStrategy().getPrice();
    Ticket ticket = new Ticket(screening, seat, price);

    // Add to booking system - this effectively "reserves" the seat
    ticketsByScreening.computeIfAbsent(screening, k -> new ArrayList<>()).add(ticket);

    return ticket;
}

// Helper method to check if a seat is already booked
private boolean isSeatBooked(Screening screening, Seat seat) {
    List<Ticket> tickets = getTicketsForScreening(screening);
    return tickets.stream().anyMatch(ticket -> ticket.getSeat().equals(seat));
}

面試技巧:討論並行能展現你處理進階情境的能力。熱門場次競爭高時偏好悲觀鎖,簡單情境則偏好樂觀鎖。記得先詢問面試官的期待。

本章小結#

主要收穫是模組化與遵循單一職責原則。Movie、ScreeningManager、Seat、Order 等元件各司其職,使系統易維護、易擴展。

設計選擇如分離 Movie 與 Screening、用 strategy pattern 處理票價,皆強調彈性與可擴展性。替代方案(如合併 Screening 與 Ticket)雖能簡化模型,但會讓座位管理變複雜。在面試中能反思並解釋這些決策,正是展現批判思維的好機會。