35+ Years Experience Netherlands Based ⚡ Fast Response Times Ruby on Rails Experts AI-Powered Development Fixed Pricing Available Senior Architects Dutch & English 35+ Years Experience Netherlands Based ⚡ Fast Response Times Ruby on Rails Experts AI-Powered Development Fixed Pricing Available Senior Architects Dutch & English
Build Custom Rails 8 Generators to Eliminate Repetitive Boilerplate

Build Custom Rails 8 Generators to Eliminate Repetitive Boilerplate

TTB Software
rails

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:

  1. Searches lib/generators/ in your app, then installed gems, then Rails itself
  2. Finds a class named ServiceObjectGenerator
  3. Calls its public methods in definition order
  4. 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].

#rails 8 #generators #automation #productivity #ruby
T

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 Touch

Share this article

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