LearnAI ToolsCareerPractice BuildsPlayDevelopmentContact
RubyIntermediate~2 hours

Library Management System

Model books, members and a library with classes, mixins (Comparable, Enumerable), custom exceptions and borrowing rules.

ClassesModules & MixinsEnumerableCustom Exceptions

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.

What You'll Build
  • A LibraryError base class with three specific subclasses.
  • A Book class that includes Comparable and sorts by author, then title.
  • A Member class with a borrowing limit.
  • A Library class that includes Enumerable, so select, map, count, min_by and group_by all just work.
  • A custom Reportable module mixed into Library.
  • 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 &:symbol shorthand — lesson 8.
  • Classes, attr_reader/attr_accessor, modules and include — lesson 9.
  • Collections and Enumerable methods — lesson 10.
  • begin/rescue for 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; end
class BookNotFoundError < LibraryError; end
class BookUnavailableError < LibraryError; end
class BorrowLimitError < LibraryError; end

Step 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}"
end
end

Step 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
end
end

Step 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")
end
end
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
end
end

Step 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
# library.rb — run with: ruby library.rb
class LibraryError < StandardError; end
class BookNotFoundError < LibraryError; end
class BookUnavailableError < LibraryError; end
class 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}"
end
end
class Member
MAX_BOOKS = 2
attr_reader :name, :books
def initialize(name)
@name = name
@books = []
end
def can_borrow?
books.size < MAX_BOOKS
end
end
module Reportable
def report(title)
lines = ["== #{title} =="]
each { |item| lines << " #{item}" }
lines.join("\n")
end
end
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
end
end
# ---- Demo ----
def attempt(description)
result = yield
puts "OK #{description}: #{result.title}"
rescue LibraryError => e
puts "ERROR #{description}: #{e.message} (#{e.class})"
end
library = Library.new
library
.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#each
puts "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

ruby library.rb

Click Run to see what this code prints.

Extend This Project

  • Add a due date to each checkout (Date.today + 14) and a library.overdue method built with select.
  • Add a Searchable module with search(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 Book objects with a to_h method and Book.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.