Every Rails project accumulates patterns—service objects, form objects, API resources—that follow the same structure. Copying and modifying existing files works, but it's error-prone and slow. Custom generators solve this by encoding project conventions into reusable templates.
The Problem with Copy-Paste Development
Consider a typical scenario: the team agrees that all service objects should include error handling, logging, and a consistent interface. Without generators, developers copy an existing service, rename it, delete the implementation, and hope they remembered every convention. Multiply this across dozens of services and the inconsistencies pile up.
Rails generators provide a better path. The framework already uses them extensively—rails generate model, rails generate controller—and the same system is available for custom project needs.
Anatomy of a Rails Generator
Generators live in lib/generators and follow a specific structure. Each generator is a Ruby class that inherits from Rails::Generators::NamedBase and uses Thor actions to create files, inject code, or modify existing content.
The generator class defines the logic, while ERB templates in a templates subdirectory provide the file content. Rails automatically locates templates based on the generator's namespace and name.
# lib/generators/service/service_generator.rb
class ServiceGenerator < Rails::Generators::NamedBase
source_root File.expand_path("templates", __dir__)
class_option :module, type: :string, default: nil,
desc: "Wrap service in a module namespace"
def create_service_file
template "service.rb.erb", service_path
end
def create_test_file
template "service_test.rb.erb", test_path
end
private
def service_path
if options[:module]
"app/services/#{options[:module].underscore}/#{file_name}_service.rb"
else
"app/services/#{file_name}_service.rb"
end
end
def test_path
if options[:module]
"test/services/#{options[:module].underscore}/#{file_name}_service_test.rb"
else
"test/services/#{file_name}_service_test.rb"
end
end
def module_namespace
options[:module]&.camelize
end
def service_class_name
"#{class_name}Service"
end
end
The source_root declaration tells Rails where to find templates. The class_option method adds command-line flags. Methods like file_name and class_name are inherited from NamedBase and automatically handle inflection.
Building the Templates
Templates use ERB to inject dynamic content. The generator instance is available, so all public and private methods can be called directly.
# lib/generators/service/templates/service.rb.erb
<% if module_namespace -%>
module <%= module_namespace %>
class <%= service_class_name %>
include ActiveModel::Validations
attr_reader :result, :errors
def initialize(params = {})
@params = params
@result = nil
@errors = ActiveModel::Errors.new(self)
end
def call
return false unless valid?
ActiveRecord::Base.transaction do
perform
end
true
rescue StandardError => e
Rails.logger.error "[<%= service_class_name %>] #{e.message}"
errors.add(:base, e.message)
false
end
private
attr_reader :params
def perform
raise NotImplementedError, "Subclasses must implement #perform"
end
end
end
<% else -%>
class <%= service_class_name %>
include ActiveModel::Validations
attr_reader :result, :errors
def initialize(params = {})
@params = params
@result = nil
@errors = ActiveModel::Errors.new(self)
end
def call
return false unless valid?
ActiveRecord::Base.transaction do
perform
end
true
rescue StandardError => e
Rails.logger.error "[<%= service_class_name %>] #{e.message}"
errors.add(:base, e.message)
false
end
private
attr_reader :params
def perform
raise NotImplementedError, "Subclasses must implement #perform"
end
end
<% end -%>
The ERB uses -%> to suppress newlines, keeping the generated code clean. Conditional logic handles the optional module namespace without duplicating the entire class body in more complex scenarios—though this example keeps both branches explicit for clarity.
Adding a Test Template
Generators should create test files alongside implementation. This ensures every generated service starts with a test scaffold.
# lib/generators/service/templates/service_test.rb.erb
require "test_helper"
<% if module_namespace -%>
module <%= module_namespace %>
class <%= service_class_name %>Test < ActiveSupport::TestCase
def setup
@service = <%= service_class_name %>.new
end
test "returns true on success" do
skip "Implement after adding perform logic"
end
test "returns false with errors on failure" do
skip "Implement after adding validation logic"
end
test "wraps execution in transaction" do
skip "Implement after adding database operations"
end
end
end
<% else -%>
class <%= service_class_name %>Test < ActiveSupport::TestCase
def setup
@service = <%= service_class_name %>.new
end
test "returns true on success" do
skip "Implement after adding perform logic"
end
test "returns false with errors on failure" do
skip "Implement after adding validation logic"
end
test "wraps execution in transaction" do
skip "Implement after adding database operations"
end
end
<% end -%>
Running the Generator
With the generator and templates in place, the command follows Rails conventions:
# Generate a basic service
$ bin/rails generate service user_registration
create app/services/user_registration_service.rb
create test/services/user_registration_service_test.rb
# Generate a namespaced service
$ bin/rails generate service order_processing --module=Checkout
create app/services/checkout/order_processing_service.rb
create test/services/checkout/order_processing_service_test.rb
# View help and options
$ bin/rails generate service --help
Advanced Generator Features
Generators support hooks to trigger other generators, inject code into existing files, and prompt for user input. A more sophisticated generator might add routes, create migration files, or update configuration.
# lib/generators/api_resource/api_resource_generator.rb
class ApiResourceGenerator < Rails::Generators::NamedBase
source_root File.expand_path("templates", __dir__)
class_option :actions, type: :array, default: %w[index show create update destroy],
desc: "Specify which actions to generate"
hook_for :test_framework, as: :scaffold
def create_controller
template "controller.rb.erb", "app/controllers/api/v1/#{plural_file_name}_controller.rb"
end
def create_serializer
template "serializer.rb.erb", "app/serializers/#{file_name}_serializer.rb"
end
def add_routes
route_content = "resources :#{plural_file_name}, only: #{options[:actions]}"
inject_into_file "config/routes.rb", after: "namespace :v1 do\n" do
" #{route_content}\n"
end
end
private
def actions
options[:actions]
end
end
The inject_into_file method modifies existing files at specific locations. The hook_for declaration triggers the test framework's scaffold generator automatically.
Generator Testing
Generators themselves should be tested to catch template errors and ensure correct file placement.
# test/lib/generators/service_generator_test.rb
require "test_helper"
require "generators/service/service_generator"
class ServiceGeneratorTest < Rails::Generators::TestCase
tests ServiceGenerator
destination Rails.root.join("tmp/generators")
setup :prepare_destination
test "generates service file" do
run_generator %w[user_registration]
assert_file "app/services/user_registration_service.rb" do |content|
assert_match(/class UserRegistrationService/, content)
assert_match(/def call/, content)
assert_match(/ActiveRecord::Base.transaction/, content)
end
end
test "generates namespaced service" do
run_generator %w[payment_processing --module=Billing]
assert_file "app/services/billing/payment_processing_service.rb" do |content|
assert_match(/module Billing/, content)
assert_match(/class PaymentProcessingService/, content)
end
end
test "generates test file" do
run_generator %w[data_import]
assert_file "test/services/data_import_service_test.rb" do |content|
assert_match(/class DataImportServiceTest/, content)
end
end
end
Common Patterns and Pitfalls
Keep templates focused. A generator that creates too many files becomes rigid. Smaller, composable generators work better than monolithic ones.
Use class options for variations. Rather than separate generators for similar patterns, use options to customize output.
Document with USAGE files. Create a lib/generators/service/USAGE file to provide help text that appears with --help.
Handle existing files gracefully. Thor's template method prompts before overwriting. For more control, use create_file with explicit collision handling.
Next Steps
Start by identifying repeated patterns in the codebase—anywhere developers copy and modify existing files. Good candidates include form objects, query objects, Stimulus controllers, and API serializers. Build generators incrementally, starting with the basic structure and adding options as needs emerge.
Generators encode team conventions into executable code. When conventions change, update the generator and all future files follow the new pattern automatically. The initial investment pays dividends in consistency and developer velocity.