Project: Library Manager

C++ · Lesson 32 of 32 9 min read Updated

Time to bring the whole course together. In this project you will build a console application for a small school or college library. Librarians can add books, lend them to members, take them back, search the catalogue and see statistics, and everything is saved to a file so nothing is lost when the program closes.

You will use most of what you have learned: a struct and a class, std::map, std::optional, std::expected, ranges and views, std::format alignment, file streams, input validation and a command loop. Build it step by step, compiling and running after each step, exactly as you would in a real job.

Step 1: plan the program

Before writing code, decide what the program must do and how the data will look.

Features:

  1. Add a book with a title and an author; the program gives it an ID number.
  2. List all books with their status.
  3. Lend a book to a member, refusing if it is already out or the ID is wrong.
  4. Accept a returned book.
  5. Search titles and authors, ignoring upper and lower case.
  6. Show how many books there are and how many are on loan.
  7. Save everything to library.txt on exit and load it on start.

Data design:

  • A Book holds an ID, a title, an author and an optional borrower. "Optional" is exactly what std::optional<std::string> expresses: either a member's name or nothing.
  • The Library class keeps books in a std::map<int, Book> keyed by ID, so lookups by ID are fast and listings come out sorted by ID.
  • Operations that can fail, like lending, return std::expected<void, std::string>: success with no value, or a message explaining the problem.

Put all the code in one file, library.cpp, in the order the steps present it, and compile with g++ -std=c++23 -Wall -Wextra library.cpp -o library (add -lstdc++exp with MinGW GCC 14 or 15).

Step 2: the Book type and helpers

Start with the headers, the Book struct and three small helper functions.

C++
// library.cpp: a small library manager in C++23
#include <algorithm>
#include <cctype>
#include <expected>
#include <format>
#include <fstream>
#include <iostream>
#include <limits>
#include <map>
#include <optional>
#include <print>
#include <ranges>
#include <sstream>
#include <string>
#include <string_view>
#include <vector>

struct Book {
    int id = 0;
    std::string title;
    std::string author;
    std::optional<std::string> borrower;     // empty while the book is on the shelf

    bool available() const { return !borrower.has_value(); }
};

std::string to_lower(std::string_view text) {
    std::string result(text);
    for (char& c : result) {
        c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
    }
    return result;
}

void print_header() {
    std::println("{:<5}{:<16}{:<22}{}", "ID", "Title", "Author", "Status");
}

void print_row(const Book& book) {
    std::string status = book.available() ? "on shelf" : "with " + *book.borrower;
    std::println("{:<5}{:<16}{:<22}{}", book.id, book.title, book.author, status);
}

Book is a plain struct because it has no rules of its own to protect; the Library class will guard the rules. to_lower converts text to lower case so that a search for "kalam" finds "Kalam". The cast to unsigned char before std::tolower is required: passing a negative char value, which can happen with non-English text, is undefined behaviour. The two print functions use the same column widths, so the header and rows always line up.

Step 3: the Library class

The class owns the map of books and hands out IDs starting from 101. Add it below the helpers.

C++
class Library {
public:
    int add_book(const std::string& title, const std::string& author) {
        int id = next_id_++;
        books_[id] = Book{id, title, author, std::nullopt};
        return id;
    }

    void print_all() const {
        print_header();
        for (const Book& book : books_ | std::views::values) {
            print_row(book);
        }
    }

    std::size_t total() const { return books_.size(); }

    std::size_t on_loan() const {
        auto count = std::ranges::count_if(books_ | std::views::values,
                                           [](const Book& b) { return !b.available(); });
        return static_cast<std::size_t>(count);
    }

    // Steps 4 to 6 add more member functions here.

private:
    std::map<int, Book> books_;
    int next_id_ = 101;
};

std::views::values walks over the Book part of each map entry, skipping the keys. std::ranges::count_if with a lambda counts the books that are out. The data members are private, so the only way to change a book is through the class's member functions.

Test the class with a temporary main at the end of the file:

C++
int main() {
    Library library;
    library.add_book("Wings of Fire", "A. P. J. Abdul Kalam");
    library.add_book("Godan", "Premchand");
    library.add_book("The Guide", "R. K. Narayan");
    library.add_book("Malgudi Days", "R. K. Narayan");

    library.print_all();
    std::println("Books: {}, on loan: {}", library.total(), library.on_loan());
}
Output
ID   Title           Author                Status
101  Wings of Fire   A. P. J. Abdul Kalam  on shelf
102  Godan           Premchand             on shelf
103  The Guide       R. K. Narayan         on shelf
104  Malgudi Days    R. K. Narayan         on shelf
Books: 4, on loan: 0

Step 4: lending and returning with std::expected

Lending can fail in two ways: the ID may not exist, or the book may already be out. Returning can fail if the ID is wrong or the book was never lent. Each function returns std::expected<void, std::string>: return {}; means success, and std::unexpected(message) carries the reason for failure. Add these inside the class, where the comment says.

C++
std::expected<void, std::string> borrow(int id, const std::string& member) {
    auto it = books_.find(id);
    if (it == books_.end()) {
        return std::unexpected(std::format("no book with id {}", id));
    }
    Book& book = it->second;
    if (!book.available()) {
        return std::unexpected(std::format("'{}' is already with {}", book.title, *book.borrower));
    }
    book.borrower = member;
    return {};
}

std::expected<void, std::string> return_book(int id) {
    auto it = books_.find(id);
    if (it == books_.end()) {
        return std::unexpected(std::format("no book with id {}", id));
    }
    if (it->second.available()) {
        return std::unexpected(std::format("'{}' was not borrowed", it->second.title));
    }
    it->second.borrower.reset();
    return {};
}

std::format builds the error messages, and borrower.reset() empties the optional again. Test the new functions with a helper that prints either "OK" or the error:

C++
void report(const std::expected<void, std::string>& result) {
    if (result) {
        std::println("OK");
    } else {
        std::println("Error: {}", result.error());
    }
}

int main() {
    Library library;
    library.add_book("Wings of Fire", "A. P. J. Abdul Kalam");
    library.add_book("Godan", "Premchand");
    library.add_book("The Guide", "R. K. Narayan");
    library.add_book("Malgudi Days", "R. K. Narayan");

    report(library.borrow(102, "Priya"));
    report(library.borrow(102, "Kabir"));
    report(library.borrow(999, "Kabir"));
    report(library.return_book(101));
    report(library.return_book(102));
    report(library.borrow(102, "Kabir"));
    library.print_all();
}
Output
OK
Error: 'Godan' is already with Priya
Error: no book with id 999
Error: 'Wings of Fire' was not borrowed
OK
OK
ID   Title           Author                Status
101  Wings of Fire   A. P. J. Abdul Kalam  on shelf
102  Godan           Premchand             with Kabir
103  The Guide       R. K. Narayan         on shelf
104  Malgudi Days    R. K. Narayan         on shelf

Trace it: Priya borrows Godan; Kabir is refused because Priya has it; ID 999 does not exist; Wings of Fire was never lent; Priya returns Godan; now Kabir can borrow it. Every failure is reported clearly and none of them crashes the program.

Step 5: searching with ranges

The search should match any part of a title or author, ignoring case. A view pipeline filters the books, and std::ranges::to collects the matches into a vector. Add this member function to the class:

C++
std::vector<Book> search(std::string_view words) const {
    std::string needle = to_lower(words);
    auto matches = books_
        | std::views::values
        | std::views::filter([&needle](const Book& b) {
              return to_lower(b.title).contains(needle) ||
                     to_lower(b.author).contains(needle);
          });
    return std::ranges::to<std::vector>(matches);
}

The function returns copies of the matching books rather than a view, because a view of books_ would become invalid as soon as the library changes. contains is the C++23 member function of std::string you met in Strings, string_view & format. With the four books from Step 3, library.search("narayan") returns books 103 and 104, and library.search("GUIDE") returns book 103.

Step 6: saving and loading

Each book becomes one line of text with fields separated by a vertical bar, for example 102|Godan|Premchand|Kabir. An empty last field means the book is on the shelf. Add these two member functions:

C++
bool save(const std::string& path) const {
    std::ofstream out(path);
    if (!out) {
        return false;
    }
    for (const Book& book : books_ | std::views::values) {
        std::println(out, "{}|{}|{}|{}", book.id, book.title, book.author,
                     book.borrower.value_or(""));
    }
    return true;
}

bool load(const std::string& path) {
    std::ifstream in(path);
    if (!in) {
        return false;
    }
    books_.clear();
    std::string line;
    while (std::getline(in, line)) {
        std::istringstream fields(line);
        std::string id_text, title, author, borrower;
        std::getline(fields, id_text, '|');
        std::getline(fields, title, '|');
        std::getline(fields, author, '|');
        std::getline(fields, borrower, '|');

        int id = std::stoi(id_text);
        Book book{id, title, author, std::nullopt};
        if (!borrower.empty()) {
            book.borrower = borrower;
        }
        books_[id] = book;
        next_id_ = std::max(next_id_, id + 1);
    }
    return true;
}

value_or("") writes an empty field for available books. When loading, next_id_ is moved past the largest ID in the file, so new books never reuse an old number. If the library from Step 4 is saved, library.txt contains:

Text
101|Wings of Fire|A. P. J. Abdul Kalam|
102|Godan|Premchand|Kabir
103|The Guide|R. K. Narayan|
104|Malgudi Days|R. K. Narayan|

This simple format breaks if a title itself contains a | character. A production program would reject such titles or use a proper format such as JSON or a SQLite database.

Step 7: the command loop

Finally, replace the test main with the real one. A helper reads a number safely, using the validation loop from User Input. The main loop reads a command word and calls the matching member function.

C++
int read_int(std::string_view prompt) {
    while (true) {
        std::print("{}", prompt);
        int value = 0;
        if (std::cin >> value) {
            return value;
        }
        if (std::cin.eof()) {
            return -1;
        }
        std::println("Please type a number.");
        std::cin.clear();
        std::cin.ignore(std::numeric_limits<std::streamsize>::max(), '\n');
    }
}

int main() {
    const std::string data_file = "library.txt";
    Library library;

    if (library.load(data_file)) {
        std::println("Loaded {} books from {}", library.total(), data_file);
    } else {
        std::println("No saved data found, starting a new library.");
    }
    std::println("Commands: list, add, borrow, return, search, stats, quit");

    std::string command;
    while (true) {
        std::print("> ");
        if (!(std::cin >> command) || command == "quit") {
            break;
        }

        if (command == "list") {
            library.print_all();
        } else if (command == "add") {
            std::string title, author;
            std::print("Title: ");
            std::getline(std::cin >> std::ws, title);
            std::print("Author: ");
            std::getline(std::cin >> std::ws, author);
            std::println("Added book {}", library.add_book(title, author));
        } else if (command == "borrow") {
            int id = read_int("Book id: ");
            std::string member;
            std::print("Member name: ");
            std::getline(std::cin >> std::ws, member);
            if (auto result = library.borrow(id, member)) {
                std::println("Issued to {}. Please return it within 14 days.", member);
            } else {
                std::println("Sorry: {}", result.error());
            }
        } else if (command == "return") {
            int id = read_int("Book id: ");
            if (auto result = library.return_book(id)) {
                std::println("Returned. Thank you!");
            } else {
                std::println("Sorry: {}", result.error());
            }
        } else if (command == "search") {
            std::string words;
            std::print("Search for: ");
            std::getline(std::cin >> std::ws, words);
            auto found = library.search(words);
            if (found.empty()) {
                std::println("No matches.");
            }
            for (const Book& book : found) {
                print_row(book);
            }
        } else if (command == "stats") {
            std::println("{} books, {} on loan", library.total(), library.on_loan());
        } else {
            std::println("Unknown command: {}", command);
        }
    }

    if (library.save(data_file)) {
        std::println("Saved {} books to {}. Goodbye!", library.total(), data_file);
    } else {
        std::println("Could not save to {}", data_file);
    }
}

Here is a sample session. It starts with the library.txt shown in Step 6, and the text after each prompt is what the librarian typed:

Output
Loaded 4 books from library.txt
Commands: list, add, borrow, return, search, stats, quit
> add
Title: Gitanjali
Author: Rabindranath Tagore
Added book 105
> borrow
Book id: 102
Member name: Priya Sharma
Sorry: 'Godan' is already with Kabir
> return
Book id: 102
Returned. Thank you!
> borrow
Book id: 102
Member name: Priya Sharma
Issued to Priya Sharma. Please return it within 14 days.
> search
Search for: tagore
105  Gitanjali       Rabindranath Tagore   on shelf
> stats
5 books, 1 on loan
> quit
Saved 5 books to library.txt. Goodbye!

Notice how the pieces fit: std::ws skips the newline left by >> before each getline, so titles and names with spaces work; failures from borrow and return_book arrive as readable messages; and the data is saved on every normal exit, including when the input ends.

What you used, and where to go next

FeatureWhere it appears
struct, class, const member functionsBook, Library
std::map, std::optionalBook storage and the borrower field
std::expected, std::unexpectedborrow and return_book
Views, count_if, std::ranges::toprint_all, on_loan, search
std::format, std::printlnError messages and aligned tables
std::ofstream, std::ifstream, std::istringstreamsave and load
Input validation with std::cinread_int and the command loop

The program is small, but its structure scales: the Library class knows nothing about the console, so you could put a graphical or web interface on top of it without changing it. Good next steps are splitting the code into library.h, library.cpp and main.cpp (see Classes & Objects), adding due dates with <chrono>, and writing automated tests for borrow and return_book.

Key points

  • Plan features and data before coding; choose types that express meaning, such as std::optional for "maybe a borrower".
  • Keep rules inside a class with private data, and report expected failures with std::expected instead of crashing.
  • Ranges and views keep searching and counting short and readable; return containers, not views, from member functions.
  • Save data in a simple text format with file streams, and update the next ID after loading.
  • Separate the user interface (the command loop) from the logic (the Library class).

Exercise

Extend the library manager with a remove command that deletes a book by ID. It must refuse to remove a book that is currently on loan. Add a member function std::expected<void, std::string> remove_book(int id) to Library and a matching branch in the command loop, then test it by removing an available book and a borrowed one.

Show solution
C++
// inside class Library
std::expected<void, std::string> remove_book(int id) {
    auto it = books_.find(id);
    if (it == books_.end()) {
        return std::unexpected(std::format("no book with id {}", id));
    }
    if (!it->second.available()) {
        return std::unexpected(std::format("'{}' is on loan to {}", it->second.title,
                                           *it->second.borrower));
    }
    books_.erase(it);
    return {};
}
C++
// inside the command loop in main
} else if (command == "remove") {
    int id = read_int("Book id: ");
    if (auto result = library.remove_book(id)) {
        std::println("Book {} removed.", id);
    } else {
        std::println("Sorry: {}", result.error());
    }
Output
> remove
Book id: 103
Book 103 removed.
> remove
Book id: 102
Sorry: 'Godan' is on loan to Priya Sharma

remove_book follows the same pattern as borrow: look up the ID, check the rule, and only then change the map. Also add remove to the list of commands printed at start-up.

Quick Quiz

1. Why does Book store the borrower as std::optional<std::string>?

2. What does borrow return when the book is already on loan?

3. Why does search return a std::vector<Book> instead of a view?

4. Why is next_id_ updated while loading?

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