Project: Seat Booking Engine with CMake & Tests
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:
booking/
CMakeLists.txt
CMakePresets.json
include/booking/seat_map.hpp
src/seat_map.cpp
app/main.cpp
tests/seat_map_test.cpp
requests.txtKeeping 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).
// 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 bookingSome decisions worth noticing:
- Each seat is a
std::optional<std::string>: empty means free, otherwise it holds the passenger's name. - The mutex is
mutableso thatconstfunctions such asfree_countcan 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_atreturns 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.
// 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 bookingThe 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.
// 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:
# 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
STATUSStep 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.
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).
{
"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:
cmake --preset debug
cmake --build --preset debug
./build/debug/booking < requests.txtOK: 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.
// 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.
ctest --preset debug
cmake --preset asan
cmake --build --preset asan
ctest --preset asan... 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
| Area | Where in the project |
|---|---|
| Library and program split | booking_core target and a thin main.cpp |
| Error handling | std::expected for normal failures, an exception for an impossible coach |
| Value types | std::optional seats, names returned by value |
| Concurrency | One mutex guarding check-then-act, a multi-threaded test |
| Build system | CMake targets, PUBLIC/PRIVATE, FetchContent, presets |
| Quality | Strict warnings, unit tests, sanitizer build |
Ideas for extending it, in the order a team might take them on:
- Add
clang-tidywith a.clang-tidyfile and fix what it reports (Core Guidelines, clang-tidy & clang-format). - Support several coaches with a
std::map<std::string, SeatMap>keyed by coach code such asS1orB2. - Save bookings to a file on exit and load them on start (File Streams).
- Make
book_anyprefer lower berths using the seat pattern from C++23 Features in Practice. - 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
mainthin, so everything important can be unit tested. - Use
std::expectedfor 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
// 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);
}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?
Code in main cannot be unit tested; a library target can be linked into tests.
2. Why must the "is the seat free?" check and the booking happen under the same lock?
This is the check-then-act race; one lock makes the pair atomic.
3. Why is mutex_ declared mutable?
Locking changes the mutex object but not the visible state of the coach.
4. What does the asan preset add?
Its CMAKE_CXX_FLAGS add -fsanitize=address,undefined to every compile and link.
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
