When you’re working on Rails apps every day, gems feel like magic. You install them, maybe tweak a config file, and boom — they work. But when I built my first Ruby gem, audit_log_vk (In case you are wondering, I'm Vk, that's why that name), I realized just how much was happening behind the curtain. As a developer, it was eye-opening to see what really goes into making a gem that feels seamless to the end user.
Here are some of the most surprising things I discovered — and why building a gem, even a small one, is totally worth it.
1. A Gem Is Just a Folder — But with Specific Files That Unlock Everything
The first surprise was that a gem is really just a Ruby project with the right folder structure. But the magic happens because of a few specific files:
audit_log_vk.gemspec
This file defines the gem itself — name, summary, author, license, and (importantly) what files to include when it’s packaged. It’s like a package.json
in the Node.js world, but with more manual responsibility.
# Set the gem version by referencing the constant inside lib/audit_log_vk/version.rb
spec.version = AuditLog::VERSION
# Define which files to include in the gem package
spec.files = Dir.chdir(__dir__) do
# Use Git to list all version-controlled files, separated by null characters
`git ls-files -z`.split("\x0").reject do |f|
# Exclude this gemspec file itself and unwanted paths (tests, CI configs, etc.)
(File.expand_path(f) == __FILE__) ||
f.start_with?(*%w[bin/ test/ spec/ features/ .git .circleci appveyor Gemfile])
end
end
🔍 What surprised me: I had to explicitly list what goes into the gem — otherwise files I thought were “just there” wouldn’t be included at all.
lib/audit_log_vk.rb
This is the gem’s main entry point. It defines the namespace and loads everything else:
require_relative "audit_log/version"
require_relative "audit_log/config"
require_relative "audit_log/context"
require_relative "audit_log/entry"
require_relative "audit_log/model"
require_relative "audit_log/helpers"
It’s the place where you glue everything together. Until I wrote it myself, I never thought about how a gem decides what to load.
lib/audit_log_vk/version.rb
And here is where you will manage the gems version, and versioning is simple but important. I quickly realized I was now responsible for semantic versioning — which is something we don't have to think about that often in the day to day.
Here is a cheatsheet to guide you:
📌 Semantic Versioning Cheatsheet
MAJOR.MINOR.PATCH
| Type of Change | Version Example | When to Use It |
|-------------------|------------------|-----------------------------------------------------------------|
| 🔥 **MAJOR** | `2.0.0` | Breaking changes — not backward compatible |
| ✨ **MINOR** | `1.1.0` | New features or enhancements — backward compatible |
| 🐛 **PATCH** | `1.0.1` | Bug fixes or small improvements — no new features or breaks |
2. bin/console Gave Me a Playground
This was a hidden gem (pun intended). bin/console lets you load your gem in a REPL before it’s published or used in an app. It's a IRB session with your gem preloaded, which lets you
- Load and test your gem’s classes and methods.
- Call stuff manually to see if it works.
- Play with configs, helpers, etc.
- Avoid having to spin up a full Rails app for quick tests.
# bin/console
require "bundler/setup"
require "audit_log_vk"
puts "Console loaded. Try AuditLogVk::Something.new"
You run it with:
bundle exec bin/console
🧠 What blew my mind: You don’t need a full Rails app to test your gem. You can load it, call its methods, and experiment — right from a console.
This helped me iterate faster without spinning up a full environment. It’s great for debugging, experimenting, or just figuring out what your gem actually exposes.
3. Generators Can Inject Files Into the User’s App
One of the coolest parts of building audit_log_vk
was creating a Rails generator to install setup files into the user’s app — just like rails generate devise:install
or sidekiq:install
.
We added a custom generator at:
lib/generators/audit_log/install_generator.rb
Inside, we used two key methods:
def copy_initializer
template "initializer.rb", "config/initializers/audit_log.rb"
end
def copy_migration
timestamp = Time.now.utc.strftime("%Y%m%d%H%M%S")
copy_file "migration.rb", "db/migrate/#{timestamp}_create_audit_log_entries.rb"
end
This setup means that when someone runs:
rails generate audit_log:install
…they automatically get:
- A ready-to-edit initializer:
config/initializers/audit_log.rb
(based on a template we created) - A migration to create the audit log entries table
This ties into the internal config system defined in lib/audit_log/config.rb, which supports:
AuditLog.configure { ... }
AuditLog.configuration.actor_method
AuditLog.reset_configuration!
🧩 Why it’s powerful: You’re not just writing Ruby logic — you’re building a full developer experience. You control what gets scaffolded, how defaults are set, and how easy it is for someone to get started with your gem.
If you’ve ever run rails generate devise:install
, sidekiq:install
, or even pundit:install
, you’ve used generators built exactly like this. They copy templates, set up initializers, and sometimes even inject routes or modify existing files.
It’s one of the best ways to make your gem feel polished and user-friendly.
4. Releasing a Gem Is a Real Publishing Process
Once the gem is ready, here’s how I pushed it:
git tag -a v0.1.0 -m "Initial release"
git push origin v0.1.0
bundle exec rake release
This command:
- Builds the gem
- Pushes it to RubyGems.org
- Pushes the tag to GitHub
🚨 What I didn’t expect: Publishing is final. If you make a mistake, you have to bump the version again. That pressure forces you to double-check everything.
RubyGems enforces immutable versioning for security and consistency:
❗ Once a gem version is published, it’s permanent and locked.
Even if you yank it (unlist), you still can’t re-use that version number again.
So if you push 1.0.0
, realize something's broken, and try to fix it — you must bump the version (e.g., to 1.0.1
or 1.1.0
) before re-releasing.
Final Thoughts: You Don’t Have to Be an Expert to Make a Gem
I’m not a senior dev. I didn’t build a complex DSL or a massive ORM layer. But building a gem — even a small one — showed me a side of Ruby and Rails I’d never seen before:
The structure behind libraries
The responsibility of packaging and versioning
The tools that make other developers’ lives easier
I know there’s still a lot I haven’t explored — things like Railtie
, Engine
, advanced integration with Rails lifecycle hooks, or deeper metaprogramming patterns. This gem didn’t require them, but I definitely plan to experiment with those concepts in future projects.
It was one of the best things I’ve done to grow as a developer. If you’ve only ever consumed gems, try building one — even something small. You’ll learn more than you expect.
Got questions about gem structure, publishing, or testing? Leave a comment or reach out — I’m always happy to share what I learned (and what confused me).
Further Reading & Resources
Official RubyGems guide to creating and publishing a gem from scratch:
📦 RubyGems Guides – Make Your Own Gem
Explains how bundle gem
scaffolds a new gem and what each file is for:
📚 Bundler – Creating a Gem
Everything you need to know about creating generators that copy templates, run commands, or modify apps:
📖 Rails Generators & Templates
Learn how to version your gem responsibly with the standard MAJOR.MINOR.PATCH
format:
💎 Semantic Versioning (semver.org)
A detailed list of all available fields in a .gemspec
file:
📘 RubyGems Specification Reference