Gems, Bundler and project layout

Gemfile and lockfile discipline, gem version constraints, gemspec versus Gemfile, Rake tasks, load paths and a layout that scales.

Gemfile and the lockfile

# Gemfile
source "https://rubygems.org"

ruby "3.3.4"

gem "rack", "~> 3.1"          # >= 3.1, < 4.0
gem "puma", "~> 6.4"
gem "pg", ">= 1.5", "< 2.0"
gem "json"

group :development, :test do
  gem "rspec", "~> 3.13"
  gem "rubocop", require: false
end

group :production do
  gem "newrelic_rpm"
end

# a gem from a git branch
gem "acme-client", git: "https://github.com/acme/client.git", branch: "main"
bundle install                 # resolves and writes Gemfile.lock
bundle update rack             # move one gem within its constraint
bundle exec rspec              # run against locked versions
bundle outdated
bundle lock --add-platform x86_64-linux arm64-darwin
  • Commit Gemfile.lock for applications, gitignore it for gems and libraries.
  • bundle exec activates exactly the locked versions; running rspec directly can load a newer system gem and give a false result.
  • The pessimistic operator ~> 3.1 means >= 3.1, < 4.0, while ~> 3.1.0 means >= 3.1.0, < 3.2.0. The extra digit tightens it a lot.
  • require: false keeps a tool out of the boot path when you only invoke it from the command line.
💡
A gem installed globally but missing from the Gemfile works on your machine and fails in CI. If code compiles locally but not in the pipeline, that discrepancy is the first thing to check.

Load paths and project layout

# a conventional layout
# myapp/
#   bin/report            executable
#   lib/myapp.rb         requires everything under lib/myapp/
#   lib/myapp/client.rb  class Myapp::Client
#   spec/                tests mirror lib/
#   Rakefile
#   myapp.gemspec
#   Gemfile

# lib/myapp.rb
require_relative "myapp/version"
require_relative "myapp/client"

module Myapp
  class Error < StandardError; end
end

# bin/report
#!/usr/bin/env ruby
require "bundler/setup"
require "myapp"
puts Myapp::Client.new(ENV.fetch("API_URL")).summary

# Rakefile
require "rspec/core/rake_task"
RSpec::Core::RakeTask.new(:spec)

task default: :spec

desc "Import a CSV of books"
task :import, [:path] do |_t, args|
  Myapp::Import.new(args[:path]).call
end
DependencyDeclared inPurpose
Runtime library of the gemgemspec add_dependencyInstalled whenever the gem is
Development toolgemspec add_development_dependencyOnly in the gem's own project
Application dependencyGemfileNot published to consumers
Ruby versionruby "3.3.4" in Gemfile or gemspecFails fast on the wrong interpreter

Use require_relative for files inside your own project and require for gems. Mixing them up makes a library work from one entry point and break from another, because the load path differs.

FAQ

Should I run bundle update regularly?
Yes, in small batches on a branch with the test suite as the gate. One giant update after a year makes it impossible to identify which of forty version bumps broke a test.
How do I debug a LoadError?
Check that the gem is in the Gemfile and installed (bundle list), that you are running through bundle exec, and that the require path matches the gem's file - some gems have a different require name than their gem name.

Packaging and distributing gems and CLIs Testing with Minitest and RSpec

Last refreshed 2026-09-18.