EthicsComputerCourses
Join training +91 90532 10052

Project: Seat Booking Engine with CMake & Tests

C++ · Lesson 46 of 46 Advanced 9 min read Updated

In this project you will build a small but realistic piece of software the way C++ teams build it at work: a library that holds the business logic, a thin command-line program that uses it, unit tests that run on every build, and build presets that include a sanitizer build. The domain is a train coach with 72 seats, where several booking agents may try to book at the same moment.

You will use many lessons from this course together: classes, std::optional, std::expected, mutexes and threads, CMake targets and presets, GoogleTest and sanitizers. The finished project is about 250 lines, small enough to understand completely and structured like code that grows to 250,000 lines.

Step 1: plan the project

The requirements:

  • A coach has a fixed number of seats, numbered from 1.
  • You can book a chosen seat, book the lowest free seat, cancel a booking, ask who sits in a seat, and see how many seats are free.
  • Booking a taken seat, a seat that does not exist, or an empty coach are normal situations, not crashes. Each must give a clear reason.
  • Several agents (threads) may book at the same time; the same seat must never be sold twice.
  • The program reads commands, one per line, so it can be tested with a file.

The folder layout separates the reusable library from the program and the tests:

Text
booking/
  CMakeLists.txt
  CMakePresets.json
  include/booking/seat_map.hpp
  src/seat_map.cpp
  app/main.cpp
  tests/seat_map_test.cpp
  requests.txt

Keeping the logic in a library and main.cpp thin is the most important design decision here. Code inside main cannot be unit tested; code in a library can be linked into both the program and the test program.

Step 2: the public interface

The header is the contract between the library and everyone who uses it. Errors are an enum class, and operations that can fail return std::expected, so the caller must look at the result (see Exceptions, optional & expected).

C++
// include/booking/seat_map.hpp
#pragma once
#include <expected>
#include <mutex>
#include <optional>
#include <string>
#include <string_view>
#include <vector>

namespace booking {

enum class BookingError { invalid_seat, already_booked, not_booked, coach_full, empty_name };

std::string_view to_string(BookingError error);

class SeatMap {
public:
    explicit SeatMap(int seats);                      // throws std::invalid_argument if < 1

    std::expected<int, BookingError> book(std::string passenger, int seat);
    std::expected<int, BookingError> book_any(std::string passenger);
    std::expected<std::string, BookingError> cancel(int seat);   // returns the passenger's name

    std::optional<std::string> passenger_at(int seat) const;
    int seat_count() const;
    int free_count() const;

private:
    bool valid(int seat) const { return seat >= 1 && seat <= seat_count(); }

    mutable std::mutex mutex_;                        // guards seats_ and booked_
    std::vector<std::optional<std::string>> seats_;   // index 0 is seat 1
    int booked_ = 0;
};

}  // namespace booking

Some decisions worth noticing:

  • Each seat is a std::optional<std::string>: empty means free, otherwise it holds the passenger's name.
  • The mutex is mutable so that const functions such as free_count can lock it. Locking does not change the visible state of the coach.
  • A constructor cannot return std::expected, so an impossible coach (zero seats) throws. That is a programming error, not a normal booking situation.
  • passenger_at returns the name by value (a copy). Returning a reference would let a caller read a string that another thread is changing.

Step 3: the implementation

Every function that touches seats_ or booked_ locks the mutex with std::lock_guard. The size of seats_ never changes after construction, so valid and seat_count can read it without locking.

C++
// src/seat_map.cpp
#include "booking/seat_map.hpp"
#include <cstddef>
#include <stdexcept>
#include <utility>

namespace booking {

std::string_view to_string(BookingError error) {
    switch (error) {
        case BookingError::invalid_seat:   return "no such seat";
        case BookingError::already_booked: return "seat already booked";
        case BookingError::not_booked:     return "seat is not booked";
        case BookingError::coach_full:     return "coach is full";
        case BookingError::empty_name:     return "passenger name is empty";
    }
    return "unknown error";
}

SeatMap::SeatMap(int seats) {
    if (seats < 1) {
        throw std::invalid_argument("a coach needs at least one seat");
    }
    seats_.resize(static_cast<std::size_t>(seats));
}

int SeatMap::seat_count() const {
    return static_cast<int>(seats_.size());
}

std::expected<int, BookingError> SeatMap::book(std::string passenger, int seat) {
    if (passenger.empty()) return std::unexpected(BookingError::empty_name);
    if (!valid(seat)) return std::unexpected(BookingError::invalid_seat);

    std::lock_guard lock(mutex_);
    auto& slot = seats_[static_cast<std::size_t>(seat - 1)];
    if (slot) return std::unexpected(BookingError::already_booked);
    slot = std::move(passenger);
    ++booked_;
    return seat;
}

std::expected<int, BookingError> SeatMap::book_any(std::string passenger) {
    if (passenger.empty()) return std::unexpected(BookingError::empty_name);

    std::lock_guard lock(mutex_);
    for (std::size_t i = 0; i < seats_.size(); ++i) {
        if (!seats_[i]) {
            seats_[i] = std::move(passenger);
            ++booked_;
            return static_cast<int>(i) + 1;
        }
    }
    return std::unexpected(BookingError::coach_full);
}

std::expected<std::string, BookingError> SeatMap::cancel(int seat) {
    if (!valid(seat)) return std::unexpected(BookingError::invalid_seat);

    std::lock_guard lock(mutex_);
    auto& slot = seats_[static_cast<std::size_t>(seat - 1)];
    if (!slot) return std::unexpected(BookingError::not_booked);
    std::string name = std::move(*slot);
    slot.reset();
    --booked_;
    return name;
}

std::optional<std::string> SeatMap::passenger_at(int seat) const {
    if (!valid(seat)) return std::nullopt;
    std::lock_guard lock(mutex_);
    return seats_[static_cast<std::size_t>(seat - 1)];
}

int SeatMap::free_count() const {
    std::lock_guard lock(mutex_);
    return seat_count() - booked_;
}

}  // namespace booking

The check "is the seat free?" and the write "now it is taken" happen under the same lock. If you locked only around the write, two agents could both see seat 12 as free and both book it. This check-then-act race is one of the most common concurrency bugs in booking and payment systems. The passenger name is taken by value and moved into the seat, so a caller passing a temporary pays for no copy at all.

Step 4: the command-line program

The program reads commands from standard input, one per line, and reports every result. Lines that are empty or start with # are skipped, so request files can contain comments.

C++
// app/main.cpp
#include "booking/seat_map.hpp"
#include <iostream>
#include <print>
#include <sstream>
#include <string>

namespace {

void fail(const std::string& line, std::string_view reason) {
    std::println("FAILED ({}): {}", line, reason);
}

void handle(booking::SeatMap& coach, const std::string& line) {
    std::istringstream in(line);
    std::string command;
    in >> command;

    if (command == "BOOK") {
        std::string name;
        int seat = 0;
        if (!(in >> name >> seat)) return fail(line, "usage: BOOK <name> <seat>");
        if (auto r = coach.book(name, seat)) std::println("OK: {} has seat {}", name, *r);
        else fail(line, booking::to_string(r.error()));
    } else if (command == "ANY") {
        std::string name;
        if (!(in >> name)) return fail(line, "usage: ANY <name>");
        if (auto r = coach.book_any(name)) std::println("OK: {} has seat {}", name, *r);
        else fail(line, booking::to_string(r.error()));
    } else if (command == "CANCEL") {
        int seat = 0;
        if (!(in >> seat)) return fail(line, "usage: CANCEL <seat>");
        if (auto r = coach.cancel(seat)) std::println("OK: cancelled seat {} ({})", seat, *r);
        else fail(line, booking::to_string(r.error()));
    } else if (command == "WHO") {
        int seat = 0;
        if (!(in >> seat)) return fail(line, "usage: WHO <seat>");
        std::println("Seat {}: {}", seat, coach.passenger_at(seat).value_or("free"));
    } else if (command == "STATUS") {
        std::println("Free seats: {} of {}", coach.free_count(), coach.seat_count());
    } else {
        fail(line, "unknown command");
    }
}

}  // namespace

int main() {
    booking::SeatMap coach(72);
    std::string line;
    while (std::getline(std::cin, line)) {
        if (line.empty() || line.starts_with('#')) continue;
        handle(coach, line);
    }
}

The anonymous namespace keeps fail and handle private to this file. return fail(...) works because both functions return void; it is a compact way to report and stop. Here is a request file:

Text
# requests.txt: one command per line
BOOK Aarav 12
BOOK Priya 12
BOOK Priya 73
ANY Kabir
ANY Meera
CANCEL 12
CANCEL 40
WHO 1
WHO 12
STATUS

Step 5: the build

The CMake file defines three targets: the booking_core library, the booking program and the booking_tests test program. Strict warnings apply to the library, and Threads::Threads is linked PUBLIC because anything using the library uses its mutex.

Text
cmake_minimum_required(VERSION 3.28)
project(booking VERSION 1.0 LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 23)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)

find_package(Threads REQUIRED)

add_library(booking_core src/seat_map.cpp)
target_include_directories(booking_core PUBLIC include)
target_link_libraries(booking_core PUBLIC Threads::Threads)
if(MSVC)
    target_compile_options(booking_core PRIVATE /W4 /permissive-)
else()
    target_compile_options(booking_core PRIVATE -Wall -Wextra -Wpedantic -Wshadow -Wconversion)
endif()

add_executable(booking app/main.cpp)
target_link_libraries(booking PRIVATE booking_core)

option(BOOKING_BUILD_TESTS "Build the unit tests" ON)
if(BOOKING_BUILD_TESTS)
    include(FetchContent)
    FetchContent_Declare(googletest
        GIT_REPOSITORY https://github.com/google/googletest.git
        GIT_TAG v1.17.0)
    set(gtest_force_shared_crt ON CACHE BOOL "" FORCE)
    FetchContent_MakeAvailable(googletest)

    enable_testing()
    add_executable(booking_tests tests/seat_map_test.cpp)
    target_link_libraries(booking_tests PRIVATE booking_core GTest::gtest_main)
    include(GoogleTest)
    gtest_discover_tests(booking_tests)
endif()

The presets file gives the team three standard builds. The asan preset adds AddressSanitizer and UndefinedBehaviorSanitizer for GCC and Clang (see Undefined Behaviour & Sanitizers).

JSON
{
  "version": 6,
  "configurePresets": [
    {
      "name": "debug",
      "generator": "Ninja",
      "binaryDir": "${sourceDir}/build/${presetName}",
      "cacheVariables": { "CMAKE_BUILD_TYPE": "Debug", "CMAKE_EXPORT_COMPILE_COMMANDS": "ON" }
    },
    {
      "name": "release",
      "inherits": "debug",
      "cacheVariables": { "CMAKE_BUILD_TYPE": "Release", "BOOKING_BUILD_TESTS": "OFF" }
    },
    {
      "name": "asan",
      "inherits": "debug",
      "cacheVariables": { "CMAKE_CXX_FLAGS": "-fsanitize=address,undefined -fno-omit-frame-pointer" }
    }
  ],
  "buildPresets": [
    { "name": "debug", "configurePreset": "debug" },
    { "name": "release", "configurePreset": "release" },
    { "name": "asan", "configurePreset": "asan" }
  ],
  "testPresets": [
    { "name": "debug", "configurePreset": "debug", "output": { "outputOnFailure": true } },
    { "name": "asan", "configurePreset": "asan", "output": { "outputOnFailure": true } }
  ]
}

Build and run the program with the request file:

Terminal
cmake --preset debug
cmake --build --preset debug
./build/debug/booking < requests.txt
Output
OK: Aarav has seat 12
FAILED (BOOK Priya 12): seat already booked
FAILED (BOOK Priya 73): no such seat
OK: Kabir has seat 1
OK: Meera has seat 2
OK: cancelled seat 12 (Aarav)
FAILED (CANCEL 40): seat is not booked
Seat 1: Kabir
Seat 12: free
Free seats: 70 of 72

In PowerShell, < redirection is not available; use Get-Content requests.txt | .\build\debug\booking.exe instead.

Step 6: unit tests, including a thread-safety test

The tests cover each rule and each error, and one test starts four threads that try to book 120 seats in a 72-seat coach. If the locking were wrong, more than 72 bookings could succeed or seats could be lost.

C++
// tests/seat_map_test.cpp
#include <gtest/gtest.h>
#include <atomic>
#include <stdexcept>
#include <thread>
#include <vector>
#include "booking/seat_map.hpp"

using booking::BookingError;
using booking::SeatMap;

TEST(SeatMap, BooksAFreeSeat) {
    SeatMap coach(8);
    auto result = coach.book("Aarav", 3);
    ASSERT_TRUE(result.has_value());
    EXPECT_EQ(*result, 3);
    EXPECT_EQ(coach.passenger_at(3), "Aarav");
    EXPECT_EQ(coach.free_count(), 7);
}

TEST(SeatMap, RejectsDoubleBooking) {
    SeatMap coach(8);
    ASSERT_TRUE(coach.book("Aarav", 3).has_value());
    EXPECT_EQ(coach.book("Priya", 3).error(), BookingError::already_booked);
    EXPECT_EQ(coach.passenger_at(3), "Aarav");
}

TEST(SeatMap, RejectsSeatsOutsideTheCoach) {
    SeatMap coach(8);
    EXPECT_EQ(coach.book("Kabir", 0).error(), BookingError::invalid_seat);
    EXPECT_EQ(coach.book("Kabir", 9).error(), BookingError::invalid_seat);
    EXPECT_TRUE(coach.book("Kabir", 8).has_value());
}

TEST(SeatMap, BookAnyFillsLowestSeatThenReportsFull) {
    SeatMap coach(2);
    EXPECT_EQ(coach.book_any("Isha").value(), 1);
    EXPECT_EQ(coach.book_any("Rohan").value(), 2);
    EXPECT_EQ(coach.book_any("Farhan").error(), BookingError::coach_full);
}

TEST(SeatMap, CancelFreesTheSeat) {
    SeatMap coach(4);
    ASSERT_TRUE(coach.book("Meera", 2).has_value());
    EXPECT_EQ(coach.cancel(2).value(), "Meera");
    EXPECT_FALSE(coach.passenger_at(2).has_value());
    EXPECT_EQ(coach.cancel(2).error(), BookingError::not_booked);
}

TEST(SeatMap, CoachWithoutSeatsIsAnError) {
    EXPECT_THROW(SeatMap{0}, std::invalid_argument);
}

TEST(SeatMap, ConcurrentAgentsNeverOversell) {
    SeatMap coach(72);
    std::atomic<int> sold{0};
    {
        std::vector<std::jthread> agents;
        for (int a = 0; a < 4; ++a) {
            agents.emplace_back([&coach, &sold] {
                for (int i = 0; i < 30; ++i) {
                    if (coach.book_any("Passenger")) ++sold;
                }
            });
        }
    }   // all agents joined here
    EXPECT_EQ(sold.load(), 72);
    EXPECT_EQ(coach.free_count(), 0);
}

Run the tests normally, then in the sanitizer build. For extra confidence in the locking, also try a build with -fsanitize=thread, which would report a data race if you removed a lock_guard.

Terminal
ctest --preset debug
cmake --preset asan
cmake --build --preset asan
ctest --preset asan
Output
...
100% tests passed, 0 tests failed out of 7
...

Each ctest run lists the seven tests with their timings (which differ on every machine) before this summary line.

What you practised

AreaWhere in the project
Library and program splitbooking_core target and a thin main.cpp
Error handlingstd::expected for normal failures, an exception for an impossible coach
Value typesstd::optional seats, names returned by value
ConcurrencyOne mutex guarding check-then-act, a multi-threaded test
Build systemCMake targets, PUBLIC/PRIVATE, FetchContent, presets
QualityStrict warnings, unit tests, sanitizer build

Ideas for extending it, in the order a team might take them on:

  1. Add clang-tidy with a .clang-tidy file and fix what it reports (Core Guidelines, clang-tidy & clang-format).
  2. Support several coaches with a std::map<std::string, SeatMap> keyed by coach code such as S1 or B2.
  3. Save bookings to a file on exit and load them on start (File Streams).
  4. Make book_any prefer lower berths using the seat pattern from C++23 Features in Practice.
  5. Run the build and tests automatically on every push with a continuous integration service.

Key points

  • Put business logic in a library target and keep main thin, so everything important can be unit tested.
  • Use std::expected for failures that are part of normal operation, and give each error a readable message.
  • Guard check-then-act sequences with one lock; test thread safety with several threads and ThreadSanitizer.
  • CMake presets give every developer the same debug, release and sanitizer builds.
  • Strict warnings, unit tests and sanitizers together catch most bugs before code is merged.

Exercise

Add a book_group(std::vector<std::string> names) member function that books adjacent seats for a family: it must find the lowest run of free seats long enough for everyone and book them all, or book nothing and return BookingError::coach_full. Return the first seat number. Write two GoogleTest tests: one where the group fits, and one where enough seats are free in total but not next to each other.

Show solution
C++
// add to the class in seat_map.hpp:
//   std::expected<int, BookingError> book_group(std::vector<std::string> names);

std::expected<int, BookingError> SeatMap::book_group(std::vector<std::string> names) {
    if (names.empty()) return std::unexpected(BookingError::empty_name);
    for (const auto& n : names) {
        if (n.empty()) return std::unexpected(BookingError::empty_name);
    }
    std::lock_guard lock(mutex_);
    const std::size_t need = names.size();
    std::size_t run = 0;
    for (std::size_t i = 0; i < seats_.size(); ++i) {
        run = seats_[i] ? 0 : run + 1;
        if (run == need) {
            std::size_t first = i + 1 - need;
            for (std::size_t k = 0; k < need; ++k) {
                seats_[first + k] = std::move(names[k]);
            }
            booked_ += static_cast<int>(need);
            return static_cast<int>(first) + 1;
        }
    }
    return std::unexpected(BookingError::coach_full);
}
C++
TEST(SeatMap, GroupGetsAdjacentSeats) {
    SeatMap coach(6);
    ASSERT_TRUE(coach.book("Aarav", 1).has_value());
    EXPECT_EQ(coach.book_group({"Priya", "Kabir", "Meera"}).value(), 2);
    EXPECT_EQ(coach.passenger_at(4), "Meera");
}

TEST(SeatMap, GroupNeedsSeatsNextToEachOther) {
    SeatMap coach(4);
    ASSERT_TRUE(coach.book("Aarav", 2).has_value());
    ASSERT_TRUE(coach.book("Isha", 4).has_value());
    EXPECT_EQ(coach.book_group({"Rohan", "Farhan"}).error(), BookingError::coach_full);
    EXPECT_EQ(coach.free_count(), 2);
}

run counts free seats in a row and resets to zero at every booked seat. The whole search and all writes happen under one lock, so another agent cannot take a seat in the middle of the group. In the second test seats 1 and 3 are free, two in total, but not adjacent, so nothing is booked and two seats stay free.

Quick Quiz

1. Why does the project put SeatMap in a library instead of in main.cpp?

2. Why must the "is the seat free?" check and the booking happen under the same lock?

3. Why is mutex_ declared mutable?

4. What does the asan preset add?

Learn C++ with a mentor

Free lessons get you started. Our Java, C & C++ Programming training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call