Build Custom Rails 8 Generators to Eliminate Repetitive Boilerplate
Every Rails team accumulates patterns. Service objects with a specific interface. Form objects that inherit from a base class. API serializers following a naming convention. You copy the last one you wrote, rename it, delete the implementation, and start over.
Custom Rails generators fix this. You run one command, and the file appears — correctly named, properly structured, with the right tests alongside it. Here’s how to build them in Rails 8.
How Rails Generators Work Under the Hood
Rails generators use Thor, a toolkit for building command-line interfaces. Every generator is a Ruby class that inherits from Rails::Generators::NamedBase (or Rails::Generators::Base for generators that don’t take a name argument).
When you run bin/rails generate service_object CreateUser, Rails:
- Searches
lib/generators/in your app, then installed gems, then Rails itself - Finds a class named
ServiceObjectGenerator - Calls its public methods in definition order
- Uses Thor’s template system to create files from ERB templates
The template resolution follows a convention: templates live in a templates/ directory next to the generator file.
Building a Service Object Generator
Let’s say your team uses this service object pattern:
# app/services/create_user.rb
class CreateUser < ApplicationService
def initialize(params:, current_user:)
@params = params
@current_user = current_user
end
def call
# implementation
end
end
With a base class:
# app/services/application_service.rb
class ApplicationService
def self.call(...)
new(...).call
end
end
Step 1: Create the Generator Class
# lib/generators/service_object/service_object_generator.rb
class ServiceObjectGenerator < Rails::Generators::NamedBase
source_root File.expand_path("templates", __dir__)
argument :attributes, type: :array, default: [], banner: "param param"
def create_service_file
template "service.rb.tt", File.join("app/services", class_path, "#{file_name}.rb")
end
def create_test_file
template "service_test.rb.tt", File.join("test/services", class_path, "#{file_name}_test.rb")
end
private
def initialize_params
attributes.map { |attr| "@#{attr} = #{attr}" }.join("\n ")
end
def initialize_signature
attributes.map { |attr| "#{attr}:" }.join(", ")
end
end
The source_root call tells Thor where to find templates. NamedBase gives you file_name, class_name, and class_path for free — handling things like bin/rails generate service_object admin/create_user producing Admin::CreateUser in the right directory.
Step 2: Write the Templates
<%# lib/generators/service_object/templates/service.rb.tt %>
<% module_namespacing do -%>
class <%= class_name %> < ApplicationService
def initialize(<%= initialize_signature %>)
<%= initialize_params %>
end
def call
# TODO: implement <%= class_name %>
end
end
<% end -%>
<%# lib/generators/service_object/templates/service_test.rb.tt %>
require "test_helper"
<% module_namespacing do -%>
class <%= class_name %>Test < ActiveSupport::TestCase
test "performs successfully" do
result = <%= class_name %>.call(<%= attributes.map { |a| "#{a}: nil" }.join(", ") %>)
assert result
end
end
<% end -%>
The .tt extension signals Thor templates. module_namespacing wraps the class in the proper module when the name includes a namespace (like Admin::CreateUser).
Step 3: Run It
$ bin/rails generate service_object CreateOrder items: user:
create app/services/create_order.rb
create test/services/create_order_test.rb
The generated service:
class CreateOrder < ApplicationService
def initialize(items:, user:)
@items = items
@user = user
end
def call
# TODO: implement CreateOrder
end
end
Adding a Destroy Generator
Rails expects generators to be reversible. When someone runs bin/rails destroy service_object CreateOrder, Rails calls the same generator methods but inverts the file operations — create_file becomes remove_file.
Template-based generators get this for free. The template method is already reversible. But if your generator does anything beyond file creation (like appending to a route file), you need to handle the reverse case:
def add_route
route "resources :#{plural_name}, only: [:create]"
end
The route helper in Rails 8 is reversible by default. For custom reversible actions, use inject_into_file with a block that can be identified and removed.
Generator Hooks: Connecting to Other Generators
Rails generators can invoke other generators. If your service objects always need a corresponding API endpoint, chain them:
class ServiceObjectGenerator < Rails::Generators::NamedBase
source_root File.expand_path("templates", __dir__)
hook_for :test_framework, as: :unit
class_option :api_endpoint, type: :boolean, default: false,
desc: "Generate a matching API controller action"
def create_service_file
template "service.rb.tt", File.join("app/services", class_path, "#{file_name}.rb")
end
def create_api_endpoint
return unless options[:api_endpoint]
generate "controller", "api/v1/#{plural_name} create --no-helper --no-assets --skip-routes"
end
end
The hook_for :test_framework line is how Rails generators automatically switch between Minitest and RSpec — it delegates test file generation to whichever framework is configured.
Testing Your Generator
Rails provides Rails::Generators::TestCase for testing generators in isolation:
# test/lib/generators/service_object_generator_test.rb
require "test_helper"
require "generators/service_object/service_object_generator"
class ServiceObjectGeneratorTest < Rails::Generators::TestCase
tests ServiceObjectGenerator
destination Rails.root.join("tmp/generators")
setup :prepare_destination
test "generates service file" do
run_generator ["create_order", "items", "user"]
assert_file "app/services/create_order.rb" do |content|
assert_match(/class CreateOrder < ApplicationService/, content)
assert_match(/def initialize\(items:, user:\)/, content)
end
end
test "generates namespaced service" do
run_generator ["admin/create_order", "items"]
assert_file "app/services/admin/create_order.rb" do |content|
assert_match(/module Admin/, content)
assert_match(/class CreateOrder < ApplicationService/, content)
end
end
test "generates test file" do
run_generator ["create_order", "items"]
assert_file "test/services/create_order_test.rb"
end
end
prepare_destination creates a clean temporary directory for each test. assert_file checks both existence and content via the optional block.
Real-World Generator Patterns
After building generators for several Rails 8 production apps, a few patterns prove consistently useful:
Form objects with validation. If you use form objects (especially with ActiveModel::Model), generate them with standard attribute declarations and test scaffolding that covers the validation rules.
Event classes for pub/sub. When using ActiveSupport::Notifications or a library like Wisper, generating event classes with a consistent interface saves time and prevents drift.
Policy objects for authorization. If you use Pundit or a custom authorization layer, generate policy files alongside their tests with the standard action methods pre-defined.
The key insight: generators work best for patterns your team has already settled on. Don’t generate what you’re still experimenting with — that just automates inconsistency.
Packaging Generators in a Gem
If you share patterns across multiple Rails apps, extract your generators into a gem. The convention:
my_gem/
├── lib/
│ └── generators/
│ └── my_gem/
│ └── service_object/
│ ├── service_object_generator.rb
│ └── templates/
│ └── service.rb.tt
When the gem is loaded, Rails automatically discovers generators under lib/generators/. Users run them as bin/rails generate my_gem:service_object CreateUser.
Practical Tips
Use --pretend during development. Running bin/rails generate service_object CreateUser --pretend shows what files would be created without writing anything. Invaluable when debugging template issues.
Check existing generators for reference. Run bin/rails generate --help to see all available generators. Read Rails’ own generator source code at railties/lib/rails/generators — the scaffold generator is surprisingly readable and demonstrates most features.
Keep templates minimal. A generator that produces 200 lines of code is probably generating too much. The sweet spot is enough structure that developers don’t have to remember the pattern, but not so much that the generated code needs heavy editing.
FAQ
How do I make a Rails generator that doesn’t require a name argument?
Inherit from Rails::Generators::Base instead of NamedBase. You won’t get the automatic file_name and class_name helpers, but you can define your own arguments and options with argument and class_option. This is useful for generators that create configuration files or initializers where the output name is fixed.
Can I override built-in Rails generators with custom ones?
Yes. Place your generator in lib/generators/ with the same name as the built-in generator, and Rails will use yours. For example, creating lib/generators/model/model_generator.rb replaces the default model generator. You can also configure generator defaults in config/application.rb using config.generators to change templates without replacing the entire generator.
What’s the difference between template and copy_file in generators?
template processes the file through ERB before writing it — any <%= %> tags get evaluated. copy_file writes the file exactly as-is. Use template for files that need dynamic content (class names, arguments) and copy_file for static files like configuration YAML or initializer boilerplate that never changes.
How do I add a generator to an existing Rails engine?
The process is identical to adding one to an app. Place generator files in lib/generators/engine_name/ within the engine. Rails discovers them automatically when the engine is loaded. Users invoke them with the engine namespace: bin/rails generate engine_name:generator_name. The Rails Engine guide covers the conventions in detail.
Do generators work with RSpec instead of Minitest?
Yes. Use the hook_for :test_framework method in your generator class. When a project is configured to use RSpec (via config.generators { |g| g.test_framework :rspec }), the hook delegates test generation to the rspec-rails generator matching your generator’s name. You’ll need a corresponding RSpec generator registered, or you can create test templates for both frameworks in your generator and check options[:test_framework].
About the Author
Roger Heykoop is a senior Ruby on Rails developer with 19+ years of Rails experience and 35+ years in software development. He specializes in Rails modernization, performance optimization, and AI-assisted development.
Get in TouchRelated Articles
Need Expert Rails Development?
Let's discuss how we can help you build or modernize your Rails application with 19+ years of expertise
Schedule a Free Consultation