Overview
This project is where Ruby's object model starts to shine. You will model a small library with three classes — Book, Member and Library — and let Ruby's built-in mixins do a surprising amount of work for you. Defining a single <=> method gives Book every comparison operator through Comparable; defining a single each method gives Library more than fifty collection methods through Enumerable.
Borrowing rules are enforced with a small hierarchy of custom exceptions, so calling code can rescue one specific problem ("that book is already out") or every library problem at once with a single rescue LibraryError.
- A
LibraryErrorbase class with three specific subclasses. - A
Bookclass that includesComparableand sorts by author, then title. - A
Memberclass with a borrowing limit. - A
Libraryclass that includesEnumerable, soselect,map,count,min_byandgroup_byall just work. - A custom
Reportablemodule mixed intoLibrary. - Checkout and return operations that raise meaningful exceptions when rules are broken.
Prerequisites
- "Everything is an object" and method calls on objects — lesson 5.
- Blocks, including
&:symbolshorthand — lesson 8. - Classes,
attr_reader/attr_accessor, modules andinclude— lesson 9. - Collections and Enumerable methods — lesson 10.
begin/rescuefor handling exceptions.
Project Structure
The program is a single file, library.rb, organized from the smallest pieces to the largest: exceptions first, then Book and Member, then the Reportable module, then Library, which coordinates them. A short demo script at the bottom exercises every feature. In a larger project, each class would move into its own file under lib/ and be loaded with require_relative.
Step 1: Custom Exceptions
Custom exceptions are just classes that inherit from StandardError. Giving them a shared parent, LibraryError, lets callers choose how specific to be when rescuing.
class LibraryError < StandardError; endclass BookNotFoundError < LibraryError; endclass BookUnavailableError < LibraryError; endclass BorrowLimitError < LibraryError; endStep 2: The Book Class with Comparable
<=> (the "spaceship" operator) returns -1, 0 or 1. By comparing two-element arrays, [author, title] <=> [other.author, other.title], books sort by author first and by title when the author is the same. Including Comparable then gives you <, >, ==, between? and clamp for free, and lets sort, min and max work on books directly.
class Book include Comparable
attr_reader :isbn, :title, :author, :year attr_accessor :borrower
def initialize(isbn:, title:, author:, year:) @isbn = isbn @title = title @author = author @year = year @borrower = nil end
def available? borrower.nil? end
def <=>(other) [author, title] <=> [other.author, other.title] end
def to_s status = available? ? "available" : "borrowed by #{borrower.name}" "#{title} by #{author} (#{year}) - #{status}" endendStep 3: The Member Class
class Member MAX_BOOKS = 2
attr_reader :name, :books
def initialize(name) @name = name @books = [] end
def can_borrow? books.size < MAX_BOOKS endendStep 4: The Library Class with Enumerable
Enumerable only asks for one thing: an each method that yields every item. Here each yields books in sorted order, so every Enumerable method — select, map, count, group_by, first — automatically sees them sorted too. Reportable is your own mixin; it relies on each as well, which shows how modules can build on whatever the including class provides. Hash#fetch with a block is used to raise a clear error for an unknown ISBN.
module Reportable def report(title) lines = ["== #{title} =="] each { |item| lines << " #{item}" } lines.join("\n") endend
class Library include Enumerable include Reportable
def initialize @books = {} # isbn => Book end
def add(book) @books[book.isbn] = book self # returning self allows chaining: library.add(a).add(b) end
def each(&block) @books.values.sort.each(&block) self end
def find_book(isbn) @books.fetch(isbn) { raise BookNotFoundError, "No book with ISBN #{isbn}" } end
def checkout(isbn, member) book = find_book(isbn) raise BookUnavailableError, "'#{book.title}' is already borrowed by #{book.borrower.name}" unless book.available? raise BorrowLimitError, "#{member.name} already has #{Member::MAX_BOOKS} books" unless member.can_borrow?
book.borrower = member member.books << book book end
def return_book(isbn) book = find_book(isbn) member = book.borrower raise LibraryError, "'#{book.title}' is not checked out" if member.nil?
member.books.delete(book) book.borrower = nil book endendStep 5: Try It Out
The attempt helper wraps an action in begin/rescue LibraryError, printing either a success message or the error message — one rescue clause handles all three specific exception types because they share a parent class.
def attempt(description) result = yield puts "OK #{description}: #{result.title}"rescue LibraryError => e puts "ERROR #{description}: #{e.message} (#{e.class})"end
attempt("Asha borrows Ruby book") { library.checkout("978-1", asha) }attempt("Ravi borrows the same book") { library.checkout("978-1", ravi) }Complete Code
# library.rb — run with: ruby library.rbclass LibraryError < StandardError; endclass BookNotFoundError < LibraryError; endclass BookUnavailableError < LibraryError; endclass BorrowLimitError < LibraryError; end
class Book include Comparable
attr_reader :isbn, :title, :author, :year attr_accessor :borrower
def initialize(isbn:, title:, author:, year:) @isbn = isbn @title = title @author = author @year = year @borrower = nil end
def available? borrower.nil? end
def <=>(other) [author, title] <=> [other.author, other.title] end
def to_s status = available? ? "available" : "borrowed by #{borrower.name}" "#{title} by #{author} (#{year}) - #{status}" endend
class Member MAX_BOOKS = 2
attr_reader :name, :books
def initialize(name) @name = name @books = [] end
def can_borrow? books.size < MAX_BOOKS endend
module Reportable def report(title) lines = ["== #{title} =="] each { |item| lines << " #{item}" } lines.join("\n") endend
class Library include Enumerable include Reportable
def initialize @books = {} # isbn => Book end
def add(book) @books[book.isbn] = book self # returning self allows chaining: library.add(a).add(b) end
def each(&block) @books.values.sort.each(&block) self end
def find_book(isbn) @books.fetch(isbn) { raise BookNotFoundError, "No book with ISBN #{isbn}" } end
def checkout(isbn, member) book = find_book(isbn) raise BookUnavailableError, "'#{book.title}' is already borrowed by #{book.borrower.name}" unless book.available? raise BorrowLimitError, "#{member.name} already has #{Member::MAX_BOOKS} books" unless member.can_borrow?
book.borrower = member member.books << book book end
def return_book(isbn) book = find_book(isbn) member = book.borrower raise LibraryError, "'#{book.title}' is not checked out" if member.nil?
member.books.delete(book) book.borrower = nil book endend
# ---- Demo ----def attempt(description) result = yield puts "OK #{description}: #{result.title}"rescue LibraryError => e puts "ERROR #{description}: #{e.message} (#{e.class})"end
library = Library.newlibrary .add(Book.new(isbn: "978-1", title: "The Well-Grounded Rubyist", author: "David A. Black", year: 2019)) .add(Book.new(isbn: "978-2", title: "Eloquent Ruby", author: "Russ Olsen", year: 2011)) .add(Book.new(isbn: "978-3", title: "Practical Object-Oriented Design", author: "Sandi Metz", year: 2018)) .add(Book.new(isbn: "978-4", title: "Design Patterns in Ruby", author: "Russ Olsen", year: 2007))
asha = Member.new("Asha")ravi = Member.new("Ravi")
puts library.report("Catalogue (sorted by author, then title)")puts
attempt("Asha borrows 978-1") { library.checkout("978-1", asha) }attempt("Ravi borrows 978-1") { library.checkout("978-1", ravi) }attempt("Asha borrows 978-2") { library.checkout("978-2", asha) }attempt("Asha borrows 978-3") { library.checkout("978-3", asha) }attempt("Ravi borrows 999-9") { library.checkout("999-9", ravi) }attempt("Return 978-1") { library.return_book("978-1") }attempt("Ravi borrows 978-1") { library.checkout("978-1", ravi) }puts
# Enumerable methods, all powered by Library#eachputs "Available now: #{library.select(&:available?).map(&:title).join(', ')}"puts "Total books: #{library.count}"puts "Oldest book: #{library.min_by(&:year).title}"puts "By author: #{library.group_by(&:author).transform_values(&:size)}"puts "Asha has: #{asha.books.map(&:title).join(', ')}"puts
# Comparable methods, powered by Book#<=>eloquent, poodr = library.find_book("978-2"), library.find_book("978-3")puts "Eloquent Ruby sorts before POODR? #{eloquent < poodr}" # "Russ Olsen" < "Sandi Metz"puts library.report("Final status")Sample Run
Click Run to see what this code prints.
Extend This Project
- Add a due date to each checkout (
Date.today + 14) and alibrary.overduemethod built withselect. - Add a
Searchablemodule withsearch(term)that matches title or author case-insensitively. - Let members join a waiting list for borrowed books and notify the first in line when the book is returned.
- Save and load the catalogue with JSON, converting
Bookobjects with ato_hmethod andBook.new(**hash). - Write Minitest tests for the borrowing rules, using
assert_raises(BorrowLimitError).
Summary
You saw how much Ruby gives you for very little code: one <=> method unlocked every comparison through Comparable, one each method unlocked the whole Enumerable toolkit, and your own Reportable module plugged into the same each. Custom exception classes turned business rules into clear, rescuable errors. These are exactly the techniques used throughout Ruby on Rails and every well-designed Ruby library.