Rails 8 Generators: Build Your Own

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.

20 claps
← Back to Blog