Organizing a Ruby Project Into Clear Components

Organizing a Ruby Project Into Clear Components

A Ruby project often begins with one file. At the start, this may contain a few variables, conditions, methods, and printed messages. As new features are added, the file becomes longer. Classes appear, data moves between methods, and repeated code starts to spread across several sections.

Project organization helps keep these parts understandable. The goal is not to create many files without purpose. The goal is to place related code together and give each component a clear responsibility.

Begin With a Simple Structure

A small Ruby project may use a structure like this:

project/
  main.rb
  lib/
    task.rb
    task_list.rb

The main.rb file starts the program. The lib folder contains the classes used by the program.

The task.rb file may contain:

class Task
  attr_reader :title
  attr_accessor :completed

  def initialize(title)
    @title = title
    @completed = false
  end

  def complete
    @completed = true
  end
end

The task_list.rb file may contain:

class TaskList
  def initialize
    @tasks = []
  end

  def add(task)
    @tasks << task
  end

  def pending
    @tasks.reject(&:completed)
  end
end

The main file connects the components:

require_relative "lib/task"
require_relative "lib/task_list"

list = TaskList.new
list.add(Task.new("Review Ruby methods"))

puts list.pending.map(&:title)

This structure separates the task itself from the collection that manages several tasks.

Use File Names That Match Responsibilities

A file name should describe the code inside it. If a file contains the Order class, order.rb is a clear choice. If it contains a formatter for an order summary, order_formatter.rb communicates that purpose.

Avoid file names such as:

helpers.rb
stuff.rb
utils.rb
misc.rb

These names often become containers for unrelated methods. A descriptive name makes navigation easier and encourages focused code.

Keep the Entry File Small

The main file should coordinate the program rather than contain all business logic.

A crowded main file may look like this:

records = File.readlines("records.txt")
cleaned = records.map(&:strip)
valid = cleaned.reject(&:empty?)
grouped = valid.group_by { |record| record[0] }

grouped.each do |letter, items|
  puts "#{letter}: #{items.length}"
end

This code works, but it combines file reading, cleaning, grouping, and output.

A clearer arrangement separates these tasks:

class RecordLoader
  def initialize(path)
    @path = path
  end

  def load
    File.readlines(@path, chomp: true)
  end
end
class RecordCleaner
  def initialize(records)
    @records = records
  end

  def clean
    @records.map(&:strip).reject(&:empty?)
  end
end
class RecordGrouper
  def initialize(records)
    @records = records
  end

  def by_first_letter
    @records.group_by { |record| record[0] }
  end
end

The entry file then becomes a short sequence:

records = RecordLoader.new("records.txt").load
cleaned = RecordCleaner.new(records).clean
groups = RecordGrouper.new(cleaned).by_first_letter

Each class explains one step.

Use Modules for Shared Behavior

Modules can group behavior used by several classes.

module Printable
  def print_line(text)
    puts "[INFO] #{text}"
  end
end

A class can include the module:

class Importer
  include Printable

  def run
    print_line("Import started")
  end
end

Modules can also group related classes under one namespace.

module Library
  class Book
  end

  class Shelf
  end
end

The classes are then referenced as:

book = Library::Book.new
shelf = Library::Shelf.new

Namespaces help prevent name conflicts and show which classes belong to the same area of the project.

Separate Data From Presentation

A class that stores information should not always decide how that information appears in every context.

class Profile
  attr_reader :name, :city

  def initialize(name, city)
    @name = name
    @city = city
  end
end

A formatter can prepare the output:

class ProfileFormatter
  def initialize(profile)
    @profile = profile
  end

  def as_text
    "#{@profile.name} — #{@profile.city}"
  end
end

This makes it easier to add another format later without changing the core class.

Introduce Service Classes for Focused Operations

A service class handles a specific process that does not naturally belong to one model.

class RegistrationProcessor
  def initialize(member, repository)
    @member = member
    @repository = repository
  end

  def call
    validate
    @repository.save(@member)
  end

  private

  def validate
    raise "Name required" if @member.name.to_s.strip.empty?
  end
end

This class coordinates validation and storage while keeping those steps outside the member object.

Organize by Domain as the Project Expands

A broader project may use folders such as:

lib/
  members/
    member.rb
    member_repository.rb
    registration_processor.rb
  reports/
    report.rb
    report_formatter.rb

This groups files by subject rather than by technical category alone.

Review Dependencies

A dependency exists when one class needs another. Too many direct dependencies can make a project difficult to change.

Instead of creating dependencies inside the class:

class Exporter
  def initialize
    @writer = FileWriter.new
  end
end

Pass them in:

class Exporter
  def initialize(writer)
    @writer = writer
  end
end

This keeps the class flexible and easier to test with a temporary replacement.

Practice Exercise

Create a small reading tracker with:

  • a Book class;
  • a ReadingList class;
  • a BookLoader class that reads titles from a file;
  • a ListFormatter class;
  • a short entry file that connects them.

Keep each class in its own file. Use require_relative to load them. Then review whether every file has one clear responsibility.

Good Ruby project organization develops gradually. Begin with meaningful names, focused classes, and short entry files. Add modules, namespaces, and service classes when they solve a visible structural problem. The result is code that is easier to read, review, and extend.

Back to blog