Search icon CANCEL
Subscription
0
Cart icon
Your Cart (0 item)
Close icon
You have no products in your basket yet
Arrow left icon
Explore Products
Best Sellers
New Releases
Books
Events
Videos
Audiobooks
Packt Hub
Free Learning
Arrow right icon
timer SALE ENDS IN
0 Days
:
00 Hours
:
00 Minutes
:
00 Seconds
Polished Ruby Programming
Polished Ruby Programming

Polished Ruby Programming: Principles and practices for building scalable, maintainable, and performant software , Second Edition

Arrow left icon
Profile Icon Jeremy Evans
Arrow right icon
$19.99 per month
Paperback Jul 2026 428 pages 2nd Edition
eBook
$43.19 $47.99
Paperback
$59.99
Paperback + Subscription
$29.99 Monthly
Table of content icon View table of contents Preview book icon Preview Book

Polished Ruby Programming

2

Designing Useful Classes

In the previous chapter, you learned how to get the most out of Ruby's core classes. However, outside of small scripts, you'll probably want to create your own classes to organize your code. How you design and structure your classes has a large effect on how intuitive and maintainable your code is.

In this chapter, we'll consider the following topics in class design:

  • When to create a class
  • How to handle trade-offs in SOLID design
  • Whether to create larger classes or more classes

By the end of this chapter, you'll have a better understanding of the principles of Ruby class design, and the trade-offs between different design approaches.

Technical requirements

In this chapter and all the chapters of this book, code given in code blocks is designed to execute on Ruby 4.0. Many of the code examples will work on earlier versions of Ruby, but not all. You will find the code files on GitHub at https://github.com/PacktPublishing/Polished-Ruby-Programming-2nd-Edition/tree/main/Chapter02.

Learning when to create a class

Object-oriented design involves creating a class for each separate type of object, and passing messages between objects. Functional design avoids the use of classes, instead using functions that operate on immutable data structures. Procedural design avoids classes as well, but uses functions that operate on mutable data structures. No one design approach is superior in all cases, and all design approaches have trade-offs. Ruby supports object-oriented design, functional design, and procedural design, and maintainable code often has a mix of all three.

One question you should consider before creating a class is, "Do I need to create a class?" There is a cost in creating a class versus using a core class. Both core classes and classes you create result in some amount of conceptual overhead. However, Ruby programmers have probably used the most common core classes already, which means they have already internalized the conceptual overhead. Creating a class, on the other hand, means that everyone who deals with the code needs to learn about the class and how it works, before they are able to use it correctly and productively.

There are two main benefits of creating a class. One benefit is that classes provide encapsulation, only allowing manipulation of instances in ways that make sense. The second benefit is that classes provide a simpler way to call functions related to the instances of the class. In Ruby, these functions are called methods. Whether these benefits outweigh the cost of the conceptual overhead is going to be situation dependent.

Let's say your application needs to store a stack of objects. This is simple to implement with the core Array class:

stack = []

# add to top of stack
stack.push(1)

# get top value from stack
stack.pop

This approach is intuitive and maintainable. However, because an Array object is used, if external code can get a reference to the array, it can violate the stack design and do this:

# add to bottom to stack!
stack.unshift(2)

To prevent this, you can create a Stack class to provide encapsulation:

class Stack
  def initialize = @stack = []

  def push(value) = @stack.push(value)

  def pop = @stack.pop
end

If you are allowing users to operate on Stack objects directly, and pass the Stack objects as arguments to other methods, this encapsulation makes sense. However, if the stack is only an implementation detail of another class, which has its own encapsulation, then creating a Stack class is probably unnecessary complexity. In addition to being less intuitive than using an Array directly, it is slower and requires more memory, due to additional object allocation and indirection.

Unfortunately, while the above code appears to provide the necessary encapulation, it actually leaks the underlying array, since Stack#push returns the underlying Array object (@stack). To ensure the necessary encapsulation, the Stack#push method should be modified to return self:

  def push(value)
    @stack.push(value)
    self
  end

The Stack#initialize method also returns the underlying Array object, but initialize methods are private by default, so users can only call initialize directly if they are choosing to bypass the encapsulation.

In the previous examples, the only benefit to creating the Stack class is information hiding, since Stack#push and Stack#pop methods only call the underlying Array methods. What if you want to require that the values in the stack are symbols, and you want to return the time the symbol spent in the stack when popping the stack? You decide to implement the behavior by creating a SymbolStack class, using an Array for storage:

class SymbolStack
  def initialize = @stack = []

You define the SymbolStack#push method to check that the argument provided is a symbol, and push the symbol and current time onto the internal stack:

  def push(sym)
    unless sym.is_a?(Symbol)
      raise TypeError, "can only push symbols onto stack"
    end
    @stack.push(sym, clock_time)
    self
  end

You define the SymbolStack#pop method to pop those values from the internal stack, and return the symbol and the amount of time the symbol spent in the stack:

  def pop
    sym, pushed_at = @stack.pop(2)
    [sym, clock_time - pushed_at]
  end

In order to calculate times accurately, you define a private SymbolStack#clock_time method that uses Process::CLOCK_MONOTONIC. This is more reliable than using Time.now, as using Time.now to calculate time durations can be affected by changes to the system time:

  private

  def clock_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
end

In this scenario, where you need both information hiding and custom behavior, defining a class usually makes sense.

One last thing to consider before creating a class is the number of places you plan to use the class. In the previous example, if you are using SymbolStack in three separate classes that have similar needs, that's a strong indication that a separate class is appropriate. However, if you are only using SymbolStack in a single class, and it doesn't need to be accessed directly by users, it may be better to inline the behavior instead of creating a custom class.

In this section, you learned principles to help you decide whether creating a class is appropriate. In the next section, you'll learn about SOLID design, and the trade-offs involved in applying it.

Handling trade-offs in SOLID design

SOLID is an acronym for five separate object-oriented design principles:

  • The single-responsibility principle
  • The open-closed principle
  • The Liskov substitution principle
  • The interface segregation principle
  • The dependency inversion principle

Using these principles can result in well-structured classes. However, these principles should not be applied dogmatically. When designing a class, you should consider whether the benefits of each principle outweigh the costs. In this section, you'll learn about each of these principles, and the trade-offs inherent in each, to help you decide whether you would benefit from applying the principle.

The single-responsibility principle

The single-responsibility principle states that a class should have only one reason to be modified, which we'll refer to as a single purpose. Classes that adhere to this principle, built to serve a single purpose, are fine and easy to use. You've probably designed classes that serve a single purpose, and haven't had problems using them or working with them.

However, the single-responsibility principle is not generally used for justifying designing a class to serve a single purpose. It's almost always used to justify splitting a single class that serves multiple purposes into multiple classes that each serve a single purpose, or at least a smaller number of purposes. This application of the principle often results in increased overall system complexity, depending on how narrowly you define the class's purpose.

Consider Ruby's String class. Ruby's String class can serve multiple purposes. It can represent text in a specific encoding. It can also represent binary data. It can be used as a builder of text or data, or as their modifier. The flexibility of Ruby's String class is one of the reasons that Ruby is a joy to use. In Ruby, you don't need to deal with separate Text, Data, TextBuilder, DataBuilder, TextModifier, and DataModifier classes, as the String class handles all of these responsibilities. If you apply the single-responsibility principle, and change this code:

str = String.new
str << "test" << "ing...1...2"

name = ARGV[1].
  to_s.
  gsub('cool', 'amazing').
  capitalize

str << ". Found: " << name
puts str

Into this code:

builder = TextBuilder.new
builder.append("test")
builder.append("ing...1...2")

modifier = TextModifier.new
name = modifier.gsub(ARGV[1].to_s, 'cool', 'amazing')
name = modifier.capitalize(name)

builder.append(". Found: ")
builder.append(name)

puts builder.as_string

Then you should consider whether the additional complexity you are adding is worth it. In many cases, libraries and applications will be more maintainable and easier to use if you employ a single class with multiple related purposes, compared to splitting the class into multiple classes, each with its own single purpose.

A good question to ask yourself when deciding whether to apply the single-responsibility principle to split up a class is, "Would I be able to use any of the new classes in additional places?" If the answer is yes, that is an indication that it may be a good idea to split up the class. However, if the answer is no, that is an indication that it may not be a good idea.

Another good question to ask yourself is, "Do I want to be able to easily replace certain parts of this class with alternative parts?" Let's say you are printing reports. Your code starts out with the ability to take a single type of input, and output a report in a single format. One design approach is to have a single Report class that holds all of the data for the report, and has all the methods used for formatting the report:

report = Report.new(data)
puts report.format

Alternatively, you could have a ReportContent class and ReportFormatter class, since the storage of data and the formatting of it into a report can be considered separate purposes:

report_content = ReportContent.new(data)
report_formatter = ReportFormatter.new
puts report_formatter.format(report_content)

Which of these approaches is better depends on whether and what type of future changes will be needed. If, in the future, you may need to support three different input types (such as flat file, SQL, and CSV) and three different report formats (such as docx, pdf, and xlsx), using separate classes can make that simpler. Instead of needing nine Report subclasses to handle all combinations of input type and report type, you can have three ReportContent subclasses and three ReportFormatter subclasses:

report_content = ReportContent.for_type(data_type).new(data)
report_formatter = ReportFormatter.for_type(report_type).new
puts report_formatter.format(report_content)

If you know in advance that you will need multiple input types and multiple report formats, separating the design into ReportContent and ReportFormatter classes upfront is a good idea. However, if you start out with only a single input format and single report format, the single Report class design is probably a better approach. You may never need to deal with multiple input types and multiple report formats, and burdening your code with excess complexity will make it harder to use. As a general principle, you should delay increasing complexity in your class designs until you actually need the complexity. It is easier to add necessary complexity than to remove unnecessary complexity, at least if you care about backward compatibility.

Applying the single-responsibility principle dogmatically leads to having many classes that each have minimal internal complexity. That allows for easy unit testing. However, it pushes complexity into the interactions between these many classes. In many cases, the additional complexity has a greater cost than the benefit provided. You should consider overall system complexity when deciding whether to apply the single-responsibility principle to split a class. If you are still unsure after consideration, you should probably not split the class.

The open-closed principle

The open-closed principle states that a class should be open for extension, but closed for modification. An extension in Ruby's case would be adding instance variables and methods, and a modification would be modifying or removing existing instance variables or methods. The open-closed principle was written to address issues with compiled software written in programming languages that are less flexible than Ruby. In Ruby, almost all classes are open for both extension and modification.

Ruby itself ignores the open-closed principle, and actively works to make sure classes aren't closed for modification. One of the most significant changes to Ruby's object model happened in Ruby 2.0, with the addition of origin classes. Origin classes are internal classes used to support the implementation of Module#prepend. Origin classes substantially increased the complexity of Ruby's object model, for the sole purpose of making modification easier, by providing a way for programmers to override any existing method and call super to get the default behavior.

Let's say you wanted to try enforcing the open-closed principle in Ruby. How would you go about it? Closing a class for extension and modification is done by calling ClassName.freeze, but closing a class for modification while leaving it open for extension is more challenging.

There are three typical ways to add methods to classes. Two of them involve defining the methods in a module, and then either including the module in the class, or prepending the module to the class. If you want to prevent modification, you could override include and prepend, and have them raise an exception if any of the modules passed have instance methods that overlap with the class's instance methods. You'd want to consider public, protected, and private methods when doing so.

To explore this, create an OpenClosed class, and add a singleton meths method to it, returning all instance methods in the given module. As instance_methods returns both public and protected methods, you need to also use private_instance_methods:

class OpenClosed
  def self.meths(m) = m.instance_methods + m.private_instance_methods

Then you override the include singleton method to check for modification (method overlap):

  def self.include(*mods)
    my_meths = meths(self)
    mods.each do
      if meths(it).intersect?(my_meths)
        raise "class closed for modification"
      end
    end
    super
  end

In this unique situation, you alias include as prepend. The main difference between these methods is the position of the passed modules in the class's method lookup hierarchy. If the methods being added don't overlap with existing methods, and you aren't passing a module that is one of the class's ancestors, then prepend is the same as include, so this doesn't break anything:

  singleton_class.alias_method :prepend, :include

The third way to add methods to a class is to define the methods directly on the class. There isn't a hook called before adding a method, so unlike overriding include and prepend, you can't prevent the method from being added.

However, you can use the method_added hook, which is called after the method has been added, at which point the class or module has already been modified. Since the method_added hook is called directly after every method, you can undo the addition of the method and raise an exception. To be able to perform the undo, you must first create an alias to each method:

  meths(self).each do
    alias_name = :"__#{it}"
    alias_method alias_name, it
  end

You will use these alias methods later to restore the original implementation of a method when the method is overridden.

Then you can define a method_added hook. You only want this hook to run when external code adds a method to the class, and not when you are undoing the addition of the method, so this uses a trick you'll learn more about in Chapter 3, Proper Variable Usage, by having a local variable defined outside the method that is modified inside the method:

  check_method = true
  define_singleton_method(:method_added) do |method|
    return unless check_method

If the method starts with the double underscore, someone could be trying to override an aliased method. Otherwise, they could be trying to override an actual method. If they are overriding the double underscore method, you need to replace it with the actual method. If they are overriding the actual method, you need to replace it with the double underscore method. So first you need to determine which replacement method to look for:

    restore_with = if method.start_with?('__')
      method[2..-1]
    else
      :"__#{method}"
    end

Then you check whether that replacement method exists. If so, then the method added is a modification, so you replace the method just defined with the method holding the original implementation, and then you raise an exception to alert the user that the class is closed for modification. If the replacement method does not exist, the method added is an extension, so you allow it:

    if private_method_defined?(restore_with) ||
           method_defined?(restore_with)
      check_method = false
      alias_method method, restore_with
      check_method = true
      raise "class closed for modification"
    end
  end
end

This approach handles most cases well, but there are still many ways around it. A user could exploit a race condition in the implementation by trying to override methods in concurrently executing threads, either by exploiting the time slices where check_method is set to false, or by having one thread override the regular method and a concurrent thread override the double underscore method at the same time. The simplest workaround for the user who wants to modify the class is to remove the method performing the check:

OpenClosed.singleton_class.remove_method(:method_added)

Long story short, because a user can always find a way to work around the restrictions, it is pointless to try to get Ruby classes to be open for extension and closed for modification. Your choices are to leave the class open for both modification and extension, or to freeze the class and close it for both modification and extension. Note that in order to truly close a class for both modification and extension, you not only need to freeze the class itself, but all of the class's ancestors as well, up to BasicObject and any modules included in BasicObject.

The Liskov substitution principle

The Liskov substitution principle states that any place where you can use an object of type T, you can also use an object of a subtype of T. In Ruby, this is generally interpreted as any place in your code where you are using an instance of a class, you can also use an instance of a subclass of that class without anything breaking. As you'll learn in Chapter 15, Static or Duck, there are Ruby core classes that do not follow this principle.

In general, this is a good principle to follow. When you subclass an existing class, if you override a method of the class, you should attempt to ensure that it accepts the same argument types and returns the same argument type.

Assume you have a class named Max that stores a maximum value, and has an over? method that returns whether the maximum value is greater than a given value:

class Max
  def initialize(max) = @max = max

  def over?(n) = @max > n
end

If you create a subclass of Max, and override over? to require an additional argument, you violate the Liskov substitution principle:

class MaxBy < Max
  def over?(n, by) = @max > n + by
end

This is because code that accepts an instance of Max, and calls over? on it with a single argument, will break if passed an instance of MaxBy. To be compliant with the Liskov substitution principle, the additional parameter must be optional:

class MaxBy < Max
  def over?(n, by: 0) = @max > n + by
end

With this approach, passing an instance of MaxBy will work, because passing a single argument to MaxBy#over? results in the same behavior as Max#over?, assuming that you initialized the MaxBy instance with a numeric value.

While the Liskov substitution principle is generally a good practice, you should not be dogmatic about applying it. In a strict sense, all subtypes that have different behavior than their supertypes or produce different results could be said to violate Liskov substitutability, even if they expose the same API. And what would be the point of having a subclass with exactly the same behavior as the parent class?

Fundamentally, attempting to adhere to the Liskov substitution principle means limiting what changes you are willing to allow in a subclass. Such limiting may not make sense in all cases. There are cases where you may want a subclass with different behavior than the superclass. Does that mean that passing an instance of that subclass to code that expects an instance of a superclass may break? Yes, but that is not necessarily a problem. As long as you don't pass subclass instances in that case, and you can still happily use subclass instances in other cases. If you think a MaxBy#over? method that requires two arguments is less error prone than one that uses an optional argument, you are probably better off requiring two arguments. You only need to ensure that you don't pass MaxBy instances to code expecting Max instances and calling the over? method on them.

As a general rule, Ruby respects the programmer. Ruby doesn't prevent behavior simply because there are ways to misuse it. Most Ruby methods are not concerned about specific classes of object; they are concerned about whether the objects respond to the expected methods appropriately. In practice, Liskov substitutability doesn't matter much in Ruby.

There is one Ruby method that will almost always break Liskov substitutability, and that you should generally avoid, and that is instance_of?. When you use instance_of:

if obj.instance_of?(Max)
  # do something
else
  # do something else
end

All subclasses of that class will break Liskov substitutability. Instead of taking the if branch, they will take the else branch. The same is true when using an equality comparison on the result of the class method:

if obj.class == Max
  # do something
else
  # do something else
end

These approaches should generally use kind_of? instead, so that subclasses are treated the same way:

if obj.kind_of?(Max)
  # do something
else
  # do something else
end

The interface segregation principle

The interface segregation principle states that clients should not be forced to depend on methods they do not use. This doesn't strictly apply to Ruby, since Ruby will only call methods that are used, but a looser interpretation is that this describes how large classes should be in terms of methods.

Classes with a large number of methods, where the programmer is only using a few of the methods, can be more difficult to understand. If 80% of your users use the same 20% of methods of a class, it may make sense to move many of the methods to a separate module, assuming that backward compatibility is not an concern. The 20% of users who need the methods can include the module in the class, while the other 80% can benefit from the smaller class.

In the real world, it's less likely that you'll have 80% of users using the same 20% of the methods. More likely, you'll have 80% of users using 20% of the methods, but which 20% are used varies from one user to the next.

For the most part, Ruby's core classes do not follow the interface segregation principle. Classes such as String, Array, and Hash have large numbers of methods. Some Rubyists would probably argue that they all have too many methods, but there would likely be differences in which methods they would vote to remove from each.

As a general principle, splitting up a large module solely because it is large is not beneficial. Having three modules with 10 methods each is not necessarily better than having one module with 30 methods. Having multiple modules, where each programmer only uses the modules they need, can reduce the runtime overhead, but it can also increase the cognitive overhead for the programmer.

You should avoid splitting a large interface into smaller interfaces if the reason for doing so is that the interface is large. It may make sense to split an interface if you can clearly separate useful methods in the interface into separate categories, where some categories will be needed in some applications but not in others. In Chapter 8, Designing for Extensibility, you'll learn more about using this kind of interface segregation via plugin systems.

The dependency inversion principle

The dependency inversion principle states that high-level modules should not depend on low-level modules, and both high-level and low-level modules should depend on abstractions. It also states that abstractions should not depend on concrete implementations, but that concrete implementations should depend on abstractions.

More complex code is harder to understand than simpler code. Whether an abstraction makes code more complex by adding unneeded flexibility, or makes code simpler by unifying concrete cases, depends on the abstraction. Abstractions are not intrinsically useful. Abstractions are only useful to the extent that they can make other code simpler.

One concrete implementation of the dependency inversion principle is dependency injection. With dependency injection, everything an object depends on should be passed into the object, to allow for maximum flexibility. Ruby doesn't require dependency injection as much as other programming languages, due to its flexibility of allowing singleton methods on almost all objects. However, dependency injection can still be used in Ruby, and there are Ruby libraries dedicated to it.

Assume you create a CurrentDay class to represent the current day. You add a work_hours method that returns the work hours for the current day, and a workday? method that returns whether the current day is a workday. You have an existing MonthlySchedule class that knows the work schedule for a given month, which you initialize with the year and month:

class CurrentDay
  def initialize
    @date = Date.today
    @schedule = MonthlySchedule.new(@date.year, @date.month)
  end

  def work_hours = @schedule.work_hours_for(@date)

  def workday? = !@schedule.holidays.include?(@date)
end

One issue with this approach is that testing the CurrentDay class becomes difficult. How can you test the workday? method? If you are testing on a workday, it will always be true. If you are testing on a non-workday, it will always be false.

One approach to handling this in the tests, without changing the implementation, is to override Date.today:

before do
  Date.singleton_class.class_eval do
    alias_method :_today, :today
    define_method(:today){Date.new(2026, 7, 29)}
  end
end

after do
  Date.singleton_class.class_eval do
    alias_method :today, :_today
    remove_method :_today
  end
end

One issue with this approach is that it prevents you from using multithreaded tests. However, the main issue is that this approach results in much more complex test code. In general, you wouldn't write this type of code directly, instead relying on your testing library's mocking/stubbing features. However, be aware that those mocking/stubbing features use this type of approach internally.

In some cases, you could use instance_variable_set to override the instance variables in the object when testing. Unfortunately, that doesn't work in this case, because the @date instance variable is used to set the @schedule instance variable inside initialize. It's also a bad idea in general, as it makes the tests brittle, since they will break if you change the implementation, even if there are no bugs in the new implementation.

For this type of situation, it makes sense to accept the date to use as an optional argument. It's probably best to use a keyword parameter for this, as that makes it easier to add a required positional parameter later:

class CurrentDay
  def initialize(date: Date.today)
    @date = date
    @schedule = MonthlySchedule.new(date.year, date.month)
  end
end

This begs the question of whether you should also allow the @schedule instance variable to be overridden via an argument:

class CurrentDay
  def initialize(date: Date.today,
                 schedule: MonthlySchedule.new(date.year, date.month))
    @date = date
    @schedule = schedule
  end
end

This is probably a bad idea unless you really need it for some other reason, as it can result in a caller passing a schedule for a different month than the month of date. An alternative that avoids that issue is to use an optional schedule_class parameter:

class CurrentDay
  def initialize(date: Date.today, schedule_class: MonthlySchedule)
    @date = date
    @schedule = schedule_class.new(date.year, date.month)
  end
end

However, you probably should not add such a parameter unless you need the ability to mock the work_hours_for or holidays methods in the schedule.

In this section, you learned how the SOLID design principles can be applied to Ruby programs. In the next section, you'll learn about trade-offs involved when choosing between a smaller number of larger classes and a larger number of smaller classes.

Deciding on larger classes or more classes

One of the decisions you will need to make when designing classes is how many classes you should have. The advantage of having fewer classes is that it usually makes the code conceptually simpler. The advantage of having more classes is that it usually makes the code more flexible. Too few classes can result in large "God" objects that are complex and difficult to refactor. Having too many classes increases the number of interactions between classes, and can result in conceptual overload. The interactions between these classes can result in higher overall system complexity in some cases. They can also make it more difficult for the programmer to figure out which classes they need to use.

Let's say you are building a library to handle the construction of HTML tables. This library will take an enumerable (rows) of enumerable objects (cells), and construct an HTML table with table/tbody/tr/td elements, with all the content in the td elements being HTML-escaped.

One approach is to use a single class. You require a standard library to handle the HTML escaping, and define an HTMLTable class, which is initialized with the rows of the table:

require 'cgi/escape'

class HTMLTable
  def initialize(rows) = @rows = rows

Then you define an HTMLTable#to_s method, which will convert the rows to an HTML string:

  def to_s
    html = String.new
    html << "<table><tbody>"
    @rows.each do |row|
      html << "<tr>"
      row.each do |cell|
        html << "<td>" << CGI.escapeHTML(cell.to_s) << "</td>"
      end
      html << "</tr>"
    end
    html << "</tbody></table>"
  end
end

This single-class approach contains all the logic in a single method, and will probably perform the best. It may not be the prettiest code, considering the manual concatenation of strings.

An alternative approach is using separate classes per element type. Ruby makes it easy to metaprogram such classes, using a base class with a to_s method that formats the type. The HTMLTable#to_s method can create instances of the type subclasses, and have the actual HTML generation confined to a single line in the Element#to_s method. You decide to try that approach. You add an HTMLTable::Element class. This class supports a set_type singleton method, for setting the type of the class, which defines the type instance method. The class calls the type method when converting the element to a string in to_s:

class HTMLTable
  class Element
    def self.set_type(type) = define_method(:type){type}

    def initialize(data) = @data = data

    def to_s = "<#{type}>#{@data}</#{type}>"
  end

You then metaprogram the creation of the four element subclasses, one for each type:

  %i"table tbody tr td".each do
    klass = Class.new(Element)
    klass.set_type(it)
    const_set(it.capitalize, klass)
  end

Then you define the HTMLTable#to_s method to create instances of each of the element subclasses, nested appropriately:

  def to_s
    Table.new(
      Tbody.new(
        @rows.map do |row|
          Tr.new(
            row.map do |cell|
              Td.new(CGI.escapeHTML(cell.to_s))
            end.join
          )
        end.join
      )
    ).to_s
  end
end

This approach uses six classes: the HTMLTable class, an Element base class, and Table, Tbody, Tr, and Td classes, which are created via metaprogramming. Each of these classes is responsible for a single thing, so this better adheres to the single-responsibility principle. However, each of the Element subclasses is doing basically the same thing. You could avoid the use of Element subclasses by passing the type as a parameter to a method of the Element class.

The best part of this design is the fact that all HTML generation happens in a single place. The worst part of this design is that it is slower, not only because of the additional object allocation, but also due to all of the temporary strings. If one of the data cells is large, the memory used will be at least eight times larger than the size of the large data cell, since the following strings will contain the large data:

  • The string containing the large data
  • The string created by CGI.escapeHTML
  • The string created in HTMLTable::Td#to_s
  • The string created in HTMLTable#to_s when joining the array of Td instances
  • The string created in HTMLTable::Tr#to_s
  • The string created in HTMLTable#to_s when joining the array of Tr instances
  • The string created in HTMLTable::Tbody#to_s
  • The string created in HTMLTable::Table#to_s

Could you get the benefit of having HTML generation in a single place, using a single-class design, while keeping the performance of the append-only design? Yes, and it isn't too difficult. You start with the single-class approach, and add a wrap method that takes the HTML string being built and the element type, yielding after adding the opening tag. This allows you to continue to use an append-only design for building the HTML:

class HTMLTable
  def wrap(html, type)
    html << "<" << type << ">"
    yield
    html << "</" << type << ">"
  end

You then modify the to_s method to use nested calls to the wrap method:

  def to_s
    html = String.new
    wrap(html, 'table') do
      wrap(html, 'tbody') do
        @rows.each do |row|
          wrap(html, 'tr') do
            row.each do |cell|
              wrap(html, 'td') do
                html << CGI.escapeHTML(cell.to_s)
              end
            end
          end
        end
      end
    end
  end
end

This approach is slightly more complex than the initial approach, but it performs almost as well. It is less repetitive, and it may be easier to expand later, such as to add support for HTML attributes on the table, tbody, tr, or td tags. It is more memory friendly than the approach that uses separate classes, as it results in three copies of the large data cell instead of eight copies:

  • The string containing the large data
  • The string created by CGI.escapeHTML
  • The string returned by HTMLTable::Table#to_s

There are cases where the separate classes approach may be preferable. If you want to allow users to use individual tags, such as tr or td, without building the entire table, the separate classes approach makes that possible, while the single-class approach does not.

The trade-off between the approaches comes down to the separate classes approach being more complex and offering additional flexibility, and the single-class approach being simpler and offering higher performance. If you need the flexibility, the separate classes approach is a better choice. Otherwise, the single-class approach makes more sense.

Summary

In this chapter, you've learned when it is a good idea to create a new class. You've learned about the five principles of SOLID design, and the trade-offs involved when applying them to Ruby classes. You've learned how to decide how many classes you should create for your application. Now you have a better understanding of the principles of Ruby class design, and the trade-offs between different approaches.

In the next chapter, you'll learn all about Ruby's different variable types, and how best to use each of them.

Questions

  1. Does creating a class make sense if you need both information hiding and custom behavior?
  2. Which SOLID principle is almost impossible to implement in Ruby?
  3. Is it useful to create classes that the user will not use directly?
Left arrow icon Right arrow icon
Download code icon Download Code

Key benefits

  • Understand the design principles behind polished Ruby code, and trade-offs involved in different implementation approaches
  • See how to use plugin systems to build libraries for maximum flexibility and performance
  • Learn about the advantages and disadvantages of different approaches to concurrency

Description

Most successful Ruby applications become more difficult to maintain as the codebase grows in size. Polished Ruby Programming, 2nd Edition provides you with the skills and advice you need to design Ruby programs and libraries that are robust, performant, scalable, and maintainable. The book takes you through possible implementation approaches for many common programming situations, discusses the trade-offs inherent in each approach, and explains why you may sometimes choose to use different approaches. You'll start by learning fundamental Ruby programming principles, such as correctly using core classes, class and method design, variable usage, error handling, and code formatting. Then you’ll move on to higher-level topics such as library design, metaprogramming, domain-specific languages, and refactoring. Finally, you'll learn about the pros and cons of different approaches to concurrency, what you should consider when deciding whether to use static types in your Ruby code, and how best to optimize your Ruby code. The 2nd edition of Polished Ruby Programming has been updated to include relevant changes between Ruby 3.0 and 4.0. While most principles discussed in the book apply to all recent Ruby versions, some of the content in the book is specific to Ruby 4.0, the latest release at the time of publication.

Who is this book for?

If you already know how to program in Ruby and want to learn more about the principles and best practices behind writing maintainable, scalable, optimized, and well-structured Ruby code, then this book is for you. Intermediate to advanced-level working knowledge of the Ruby programming language is expected to get the most out of this book.

What you will learn

  • Use Ruby's core classes and design custom classes effectively
  • Explore the principles behind variable usage and method argument choice
  • Design extensible libraries and plugin systems in Ruby
  • Use metaprogramming and DSLs to avoid code redundancy
  • Implement different approaches to testing and understand their trade-offs
  • Discover design patterns, refactoring, and optimization with Ruby
  • Learn about the trade-offs inherent in different concurrency approaches
  • Determine whether using static types in Ruby makes sense for you

Product Details

Country selected
Publication date, Length, Edition, Language, ISBN-13
Publication date : Jul 10, 2026
Length: 428 pages
Edition : 2nd
Language : English
ISBN-13 : 9781803243641
Category :
Languages :

Product Details

Publication date : Jul 10, 2026
Length: 428 pages
Edition : 2nd
Language : English
ISBN-13 : 9781803243641
Category :
Languages :

Packt Subscriptions

See our plans and pricing
Modal Close icon
$19.99 billed monthly
Feature tick icon Unlimited access to Packt's library of 7,000+ practical books and videos
Feature tick icon Constantly refreshed with 50+ new titles a month
Feature tick icon Exclusive Early access to books as they're written
Feature tick icon Solve problems while you work with advanced search and reference features
Feature tick icon Offline reading on the mobile app
Feature tick icon Simple pricing, no contract
$199.99 billed annually
Feature tick icon Unlimited access to Packt's library of 7,000+ practical books and videos
Feature tick icon Constantly refreshed with 50+ new titles a month
Feature tick icon Exclusive Early access to books as they're written
Feature tick icon Solve problems while you work with advanced search and reference features
Feature tick icon Offline reading on the mobile app
Feature tick icon Choose a DRM-free eBook or Video every month to keep
Feature tick icon PLUS own as many other DRM-free eBooks or Videos as you like for just $5 each
Feature tick icon Exclusive print discounts
$279.99 billed in 18 months
Feature tick icon Unlimited access to Packt's library of 7,000+ practical books and videos
Feature tick icon Constantly refreshed with 50+ new titles a month
Feature tick icon Exclusive Early access to books as they're written
Feature tick icon Solve problems while you work with advanced search and reference features
Feature tick icon Offline reading on the mobile app
Feature tick icon Choose a DRM-free eBook or Video every month to keep
Feature tick icon PLUS own as many other DRM-free eBooks or Videos as you like for just $5 each
Feature tick icon Exclusive print discounts

Table of Contents

19 Chapters
Chapter 1: Getting the Most out of Core Classes Chevron down icon Chevron up icon
Chapter 2: Designing Useful Classes Chevron down icon Chevron up icon
Chapter 3: Proper Variable Usage Chevron down icon Chevron up icon
Chapter 4: Methods and Their Parameters Chevron down icon Chevron up icon
Chapter 5: Handling Errors Chevron down icon Chevron up icon
Chapter 6: Formatting Code for Easy Reading Chevron down icon Chevron up icon
Chapter 7: Designing Your Library Chevron down icon Chevron up icon
Chapter 8: Designing for Extensibility Chevron down icon Chevron up icon
Chapter 9: Metaprogramming and When to Use It Chevron down icon Chevron up icon
Chapter 10: Designing Useful Domain-Specific Languages Chevron down icon Chevron up icon
Chapter 11: Testing to Ensure Your Code Works Chevron down icon Chevron up icon
Chapter 12: Handling Change Chevron down icon Chevron up icon
Chapter 13: Using Common Design Patterns Chevron down icon Chevron up icon
Chapter 14: Choosing Your Concurrency Approach Chevron down icon Chevron up icon
Chapter 15: Static or Duck Chevron down icon Chevron up icon
Chapter 16: Optimizing Your Code Chevron down icon Chevron up icon
Chapter 17: Unlock Your Exclusive Benefits Chevron down icon Chevron up icon
Other Books You May Enjoy Chevron down icon Chevron up icon
Index Chevron down icon Chevron up icon
Get free access to Packt library with over 7500+ books and video courses for 7 days!
Start Free Trial

FAQs

What is included in a Packt subscription? Chevron down icon Chevron up icon

A subscription provides you with full access to view all Packt and licnesed content online, this includes exclusive access to Early Access titles. Depending on the tier chosen you can also earn credits and discounts to use for owning content

How can I cancel my subscription? Chevron down icon Chevron up icon

To cancel your subscription with us simply go to the account page - found in the top right of the page or at https://subscription.packtpub.com/my-account/subscription - From here you will see the ‘cancel subscription’ button in the grey box with your subscription information in.

What are credits? Chevron down icon Chevron up icon

Credits can be earned from reading 40 section of any title within the payment cycle - a month starting from the day of subscription payment. You also earn a Credit every month if you subscribe to our annual or 18 month plans. Credits can be used to buy books DRM free, the same way that you would pay for a book. Your credits can be found in the subscription homepage - subscription.packtpub.com - clicking on ‘the my’ library dropdown and selecting ‘credits’.

What happens if an Early Access Course is cancelled? Chevron down icon Chevron up icon

Projects are rarely cancelled, but sometimes it's unavoidable. If an Early Access course is cancelled or excessively delayed, you can exchange your purchase for another course. For further details, please contact us here.

Where can I send feedback about an Early Access title? Chevron down icon Chevron up icon

If you have any feedback about the product you're reading, or Early Access in general, then please fill out a contact form here and we'll make sure the feedback gets to the right team. 

Can I download the code files for Early Access titles? Chevron down icon Chevron up icon

We try to ensure that all books in Early Access have code available to use, download, and fork on GitHub. This helps us be more agile in the development of the book, and helps keep the often changing code base of new versions and new technologies as up to date as possible. Unfortunately, however, there will be rare cases when it is not possible for us to have downloadable code samples available until publication.

When we publish the book, the code files will also be available to download from the Packt website.

How accurate is the publication date? Chevron down icon Chevron up icon

The publication date is as accurate as we can be at any point in the project. Unfortunately, delays can happen. Often those delays are out of our control, such as changes to the technology code base or delays in the tech release. We do our best to give you an accurate estimate of the publication date at any given time, and as more chapters are delivered, the more accurate the delivery date will become.

How will I know when new chapters are ready? Chevron down icon Chevron up icon

We'll let you know every time there has been an update to a course that you've bought in Early Access. You'll get an email to let you know there has been a new chapter, or a change to a previous chapter. The new chapters are automatically added to your account, so you can also check back there any time you're ready and download or read them online.

I am a Packt subscriber, do I get Early Access? Chevron down icon Chevron up icon

Yes, all Early Access content is fully available through your subscription. You will need to have a paid for or active trial subscription in order to access all titles.

How is Early Access delivered? Chevron down icon Chevron up icon

Early Access is currently only available as a PDF or through our online reader. As we make changes or add new chapters, the files in your Packt account will be updated so you can download them again or view them online immediately.

How do I buy Early Access content? Chevron down icon Chevron up icon

Early Access is a way of us getting our content to you quicker, but the method of buying the Early Access course is still the same. Just find the course you want to buy, go through the check-out steps, and you’ll get a confirmation email from us with information and a link to the relevant Early Access courses.

What is Early Access? Chevron down icon Chevron up icon

Keeping up to date with the latest technology is difficult; new versions, new frameworks, new techniques. This feature gives you a head-start to our content, as it's being created. With Early Access you'll receive each chapter as it's written, and get regular updates throughout the product's development, as well as the final course as soon as it's ready.We created Early Access as a means of giving you the information you need, as soon as it's available. As we go through the process of developing a course, 99% of it can be ready but we can't publish until that last 1% falls in to place. Early Access helps to unlock the potential of our content early, to help you start your learning when you need it most. You not only get access to every chapter as it's delivered, edited, and updated, but you'll also get the finalized, DRM-free product to download in any format you want when it's published. As a member of Packt, you'll also be eligible for our exclusive offers, including a free course every day, and discounts on new and popular titles.

Modal Close icon
Modal Close icon