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
Bookclass; -
a
ReadingListclass; -
a
BookLoaderclass that reads titles from a file; -
a
ListFormatterclass; - 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.