Ruby from the ground up

Putting it together: a small library

A capstone that uses almost everything: classes with state, a mixin, Enumerable, blocks, sorted and grouped reports, a custom error, and JSON out of the standard library.

Part 13 of 13

Advanced22 min readBack to Courses

The shape of the program

Two classes and a module. Book is a value: three readers, a to_s that lines up in a table, and a to_h that says what it is when the outside world asks for data. Library holds books and — this is the interesting part — includes Enumerable, so defining each is enough for it to be sorted, grouped and searched with the same methods an array answers.

The module Summarisable supplies the report. It is a mixin because summarising is a thing a list of books can do, not a kind of book — and it uses map and join, which the class only has because it mixed in Enumerable. Modules feeding on modules is the ordinary texture of Ruby, not a trick.

Rules that refuse

add validates before it stores: a duplicate title is a LibraryError, and a year outside the range printing has existed is a LibraryError too. Raising by name, with a message written for a person, is what makes the failure readable when it arrives three calls away — and the capstone catches its own error at the bottom to show the message travelling out and being handled.

Notice what is absent. No loops build arrays by hand — blocks do that. No type checks — the objects know what they are. No manual string assembly for the output — format lines up the columns and JSON.generate writes the machine-readable version, both from the standard library.

Where a real project goes next

A real library system would add persistence, a to_s that is not for humans, tests, and a gem specification so the code can be installed. The techniques are the ones from this course: another class for the store, another module for the behaviour it shares, blocks for the queries, and exceptions for the rules.

The habit worth keeping is the one this capstone shows at the top: give every step a name — a class, a method, a module — and the program reads like the description of what it does. That is what Ruby was designed to make possible.

The seams that keep it readable

The capstone separates three jobs that are easy to tangle: a value class holds data, a collection class holds the rules for adding and finding, and a module holds a report that any collection can produce. Each seam is a place where a change stays local. Change the table layout in the report module, and nothing about storage moves; add a validation to Library, and every caller gets it.

Enumerable is the contract that makes the collection feel native. Defining each — yielding every item in turn — is the whole price of admission; map, select, sort_by, count and min_by arrive from the module and are written in terms of that one method. A collection that honours each can be handed to any code that expects something enumerable, which is a quieter kind of reuse than inheritance.

A capstone is a rehearsal

The point of building the library end to end is not the library. It is meeting the seams: where a value becomes a row, where a rule refuses, where a collection becomes enumerable. Those seams appear in every program of any size, and recognising them is most of the design work.

Read the code in the order it runs: the class definitions first, then the three books, then the report, then the refusals. Each line should be explainable by a chapter you have read, and the ones that are not are the ones to revisit.

The next step is a test

The natural next addition is a file of assertions: adding a duplicate raises, a bad year raises, the oldest book is the 1961 one, the JSON round trip keeps the titles. Those claims pin the behaviour, and they let you change the report layout without wondering whether the rules still hold.

Three things to watch as the program grows. to_s is for people and to_h is for data — putting both in one method makes a change to the table break the JSON. Returning the internal array from an accessor lets a caller add books without passing the validation. And a library should raise its own named error rather than rescue everything at the edge: the caller decides what a failure means, not the code that discovered it.

The whole capstone, one page

require 'json'

class LibraryError < StandardError; end

class Book
  attr_reader :title, :author, :year

  def initialize(title, author, year)
    @title = title
    @author = author
    @year = year
  end

  def to_s
    format('%-22s %-16s %d', @title, @author, @year)
  end

  def to_h
    { title: @title, author: @author, year: @year }
  end
end

module Summarisable
  def summary
    rows = map { |book| book.to_s }
    rows.unshift(format('%-22s %-16s %s', 'TITLE', 'AUTHOR', 'YEAR'))
    rows.join("
")
  end
end

class Library
  include Enumerable
  include Summarisable

  def initialize
    @books = []
  end

  def add(book)
    raise LibraryError, "#{book.title} is already on the shelf" if any? { |b| b.title == book.title }
    raise LibraryError, "#{book.year} is not a year" unless (1450..2100).cover?(book.year)
    @books << book
    self
  end

  def each
    @books.each { |book| yield book }
  end

  def oldest
    min_by { |book| book.year }
  end
end

library = Library.new
library.add(Book.new('Dune', 'Frank Herbert', 1965))
library.add(Book.new('Solaris', 'Stanislaw Lem', 1961))
library.add(Book.new('Kindred', 'Octavia Butler', 1979))

puts library.summary
p library.group_by { |book| book.year < 1970 ? 'sixties' : 'seventies' }.keys
puts JSON.generate(library.map { |book| book.to_h })

The library, end to end

Run it and check each report against the four books. Then add a fifth book, a `top_by_year` method that returns the three most recent, and one more validation rule of your own.

Output
Click "Run" to execute your code

A capstone is a reunion, not an exam: every line above is something an earlier chapter taught. If one of them feels unfamiliar, that chapter is worth one more read — the code will still be here.

Try these next

  • Add a remove(title) method that raises the library error when the title is not on the shelf.
  • Write a second report module that lists books by decade, and include it beside the first.
  • Export the library as JSON, then parse it back and rebuild the objects.

A playlist, end to end

Run it and check the report and the JSON against the three tracks. Then add a fourth track, a `longest` method, and one more validation rule of your own.

Output
Click "Run" to execute your code

Check what you learned

One question at a time. A wrong answer explains itself — take another go.

Part 1 of 5

Why does Library only define each and yet answer sort_by?