A generator template file with a gear icon feeds into a conveyor belt producing three output files — a model, migration, and spec — illustrating how a single custom Rails generator command can scaffold multiple files from one template, eliminating repetitive boilerplate for patterns like namespaced models and service objects

Rails ships with generators for models, controllers, migrations, scaffolds, and more. But every team accumulates patterns that the built-in generators do not cover: service objects, form objects, namespaced models with specific concerns, policy classes, API endpoints with a consistent structure. You can build these by hand every time, or you can write a custom generator once and let the command line do the work from that point forward.

This post covers how to build custom Rails generators from scratch. We will work through two practical examples: a namespaced model generator that creates correctly configured model files and migrations, and an API service generator that scaffolds a service object with its spec and an optional route injection. Each example introduces new generator capabilities, so the concepts build on each other as you read.

How Generators Work

A Rails generator is a Ruby class that inherits from Rails::Generators::Base (or one of its subclasses) and lives in lib/generators/. Each public method in the class runs sequentially when the generator is invoked. Rails discovers the generator by convention based on the file path and class name. No registration or configuration is required.

Generators are built on top of Thor, which provides the argument parsing, file manipulation actions, and template processing. Understanding a few Thor primitives (create_file, template, insert_into_file, and the argument/option declarations) is enough to build useful generators for most patterns.

There are two base classes to know. Rails::Generators::Base is for generators that do not require a name argument (rails generate thing). Rails::Generators::NamedBase requires a name as the first argument (rails generate thing SomeName) and gives you helper methods like class_name, file_path, file_name, and plural_file_name that handle inflection automatically.

Example 1: Namespaced Model Generator

The built-in rails generate model does not handle namespacing well. As we covered in our post on namespaced model associations, every namespaced model needs its table name set explicitly, the appropriate concern included, and the migration written to account for the prefixed table name. A custom generator can handle all of that in one command.

The Simplest Version

Here is a minimal generator that creates a namespaced model file using create_file:

# lib/generators/namespaced_model_generator.rb

class NamespacedModelGenerator < Rails::Generators::NamedBase
  def create_model_file
    create_file "app/models/#{file_path}.rb", <<~RUBY
      class #{class_name} < ApplicationRecord
        include Namespaceable
      end
    RUBY
  end
end

Running rails generate namespaced_model Billing::Invoice creates app/models/billing/invoice.rb with the concern already included. The NamedBase class provides class_name (returns "Billing::Invoice") and file_path (returns "billing/invoice") automatically.

This works, but hardcoding the file content as a heredoc gets unwieldy for anything beyond a few lines. That is where templates come in.

Adding Templates

Thor templates use ERB syntax and the .tt file extension. The generator can access any of its methods and variables from within the template. To use templates, move the generator into its own directory and add a templates/ folder alongside it:

lib/generators/namespaced_model/
  namespaced_model_generator.rb
  templates/
    model.rb.tt
    migration.rb.tt

The model template:

# lib/generators/namespaced_model/templates/model.rb.tt

class <%= class_name %> < ApplicationRecord
  include Namespaceable
<% parsed_attributes.each do |attr| -%>
<% if attr[:type] == "references" -%>
  belongs_to :<%= attr[:name] %>
<% end -%>
<% end -%>
end

The migration template:

# lib/generators/namespaced_model/templates/migration.rb.tt

class <%= migration_class_name %> < ActiveRecord::Migration[<%= Rails::VERSION::MAJOR %>.<%= Rails::VERSION::MINOR %>]
  def change
    create_table :<%= table_name %> do |t|
<% parsed_attributes.each do |attr| -%>
<% if attr[:type] == "references" -%>
      t.references :<%= attr[:name] %>, null: false, foreign_key: { to_table: :<%= attr[:name].pluralize %> }
<% else -%>
      t.<%= attr[:type] %> :<%= attr[:name] %>
<% end -%>
<% end -%>

      t.timestamps
    end
  end
end

And the updated generator class that uses them:

# lib/generators/namespaced_model/namespaced_model_generator.rb

class NamespacedModelGenerator < Rails::Generators::NamedBase
  source_root File.expand_path("templates", __dir__)

  argument :attributes, type: :array, default: [],
           banner: "field[:type] field[:type]"

  def create_model_file
    template "model.rb", "app/models/#{file_path}.rb"
  end

  def create_migration_file
    template "migration.rb",
      "db/migrate/#{migration_timestamp}_create_#{table_name}.rb"
  end

  private

  def table_name
    class_name.underscore.tr("/", "_").pluralize
  end

  def migration_timestamp
    Time.now.utc.strftime("%Y%m%d%H%M%S")
  end

  def migration_class_name
    "Create#{class_name.gsub('::', '')}".pluralize
  end

  def parsed_attributes
    attributes.map do |attr|
      name, type = attr.split(":")
      { name: name, type: type || "string" }
    end
  end
end

The source_root tells the generator where to find template files. The template method reads the .tt file (the extension is assumed), processes the ERB, and writes the result to the destination. The argument declaration adds attribute support, so rails generate namespaced_model Billing::Invoice clinic:references amount:decimal status:string produces both the model and migration with the correct fields and table name.

Example 2: API Service Generator

Service objects are a common Rails pattern, but there is no built-in generator for them. Every time someone on the team creates one, they have to remember the directory structure, the class naming convention, the base class (if you have one), and where the spec goes. A generator eliminates that friction.

This generator creates a service class and its spec, and optionally injects a route. It introduces two new capabilities: creating multiple files from a single generator and editing existing files with insert_into_file.

The Generator Class

# lib/generators/api_service/api_service_generator.rb

class ApiServiceGenerator < Rails::Generators::NamedBase
  source_root File.expand_path("templates", __dir__)

  class_option :route, type: :boolean, default: false,
               desc: "Add a route for the service endpoint"

  class_option :skip_spec, type: :boolean, default: false,
               desc: "Skip spec file generation"

  def create_service_file
    template "service.rb", "app/services/#{file_path}_service.rb"
  end

  def create_spec_file
    return if options[:skip_spec]

    template "service_spec.rb", "spec/services/#{file_path}_service_spec.rb"
  end

  def add_route
    return unless options[:route]

    namespace_parts = class_name.split("::")
    service_name = namespace_parts.pop.underscore

    route_line = "    post '#{service_name}', to: '#{file_path}#create'\n"

    if namespace_parts.any?
      namespace_block = namespace_parts.map(&:underscore).first
      insert_into_file "config/routes.rb",
        route_line,
        after: /namespace :#{namespace_block} do\n/
    else
      insert_into_file "config/routes.rb",
        "  #{route_line}",
        after: "Rails.application.routes.draw do\n"
    end
  end

  private

  def service_class_name
    "#{class_name}Service"
  end
end

The class_option declarations add flag-based options. Running rails generate api_service Billing::Charges --route creates the service, the spec, and injects the route. Running rails generate api_service Billing::Charges --skip-spec creates only the service file. Options are accessed through the options hash.

The Templates

The service template establishes a consistent structure that every service object in the application will follow:

# lib/generators/api_service/templates/service.rb.tt

# frozen_string_literal: true

class <%= service_class_name %>
  def initialize(<%= file_name %>_params)
    @params = <%= file_name %>_params
  end

  def call
    # TODO: Implement service logic
  end

  private

  attr_reader :params
end

The spec template gives the developer a working test structure to fill in:

# lib/generators/api_service/templates/service_spec.rb.tt

# frozen_string_literal: true

require "rails_helper"

RSpec.describe <%= service_class_name %> do
  describe "#call" do
    subject(:service) { described_class.new(params) }

    let(:params) { {} }

    it "performs the expected operation" do
      # TODO: Add test expectations
      expect(service.call).to be_nil
    end
  end
end

Running rails generate api_service Billing::Charges --route produces:

# app/services/billing/charges_service.rb

class Billing::ChargesService
  def initialize(charges_params)
    @params = charges_params
  end

  def call
    # TODO: Implement service logic
  end

  private

  attr_reader :params
end
# spec/services/billing/charges_service_spec.rb

RSpec.describe Billing::ChargesService do
  describe "#call" do
    subject(:service) { described_class.new(params) }

    let(:params) { {} }

    it "performs the expected operation" do
      expect(service.call).to be_nil
    end
  end
end

And the route injection adds a line inside the namespace :billing block in config/routes.rb.

Editing Existing Files

The insert_into_file method (aliased as inject_into_file) is the key action for modifying files that already exist. It takes a file path, the content to insert, and an :after or :before option that specifies where to place the content. The match is a string or regex.

Be specific with your match strings. A vague match like after: "do\n" could insert content in the wrong block if the file has multiple do blocks. In the API service generator, we match on the specific namespace block (namespace :billing do) to ensure the route lands in the right place.

Other useful file-editing actions include gsub_file for find-and-replace, append_to_file for adding content to the end of a file, and comment_lines / uncomment_lines for toggling comments. The full list is in the Thor actions documentation.

Testing Your Generators

Rails provides Rails::Generators::TestCase for testing custom generators. It runs the generator in a temporary directory and gives you assertions for checking that files were created with the expected content.

# test/lib/generators/api_service_generator_test.rb

require "test_helper"
require "generators/api_service/api_service_generator"

class ApiServiceGeneratorTest < Rails::Generators::TestCase
  tests ApiServiceGenerator
  destination Rails.root.join("tmp/generators")
  setup :prepare_destination

  test "creates the service file" do
    run_generator ["Billing::Charges"]

    assert_file "app/services/billing/charges_service.rb" do |content|
      assert_match(/class Billing::ChargesService/, content)
      assert_match(/def call/, content)
    end
  end

  test "creates the spec file" do
    run_generator ["Billing::Charges"]

    assert_file "spec/services/billing/charges_service_spec.rb" do |content|
      assert_match(/RSpec.describe Billing::ChargesService/, content)
    end
  end

  test "skips spec with --skip-spec flag" do
    run_generator ["Billing::Charges", "--skip-spec"]

    assert_file "app/services/billing/charges_service.rb"
    assert_no_file "spec/services/billing/charges_service_spec.rb"
  end
end

Generator tests are worth writing even for simple generators. Manual testing means running the generator, inspecting the output, then cleaning up the generated files before trying again. The test case handles all of that automatically and catches regressions when you change the templates.

One caveat: Rails::Generators::TestCase uses Minitest. If your project uses RSpec, there is no official equivalent. You can write integration-style specs that invoke the generator and check the filesystem, but you lose the built-in assertions like assert_file and assert_migration.

Tips for Building Generators

  1. Start with create_file, then move to templates. A heredoc string is fine for prototyping. Once the output is stable, move it into a .tt template for readability and maintainability.
  2. Use NamedBase when you need a name. It gives you class_name, file_path, file_name, singular_name, plural_name, and other inflection helpers for free. Only use Base when the generator truly does not need a name argument.
  3. Keep templates in the same directory as the generator. The conventional structure is lib/generators/your_generator/ with a templates/ subdirectory. This keeps everything colocated and easy to find.
  4. Make file-editing idempotent. If your generator uses insert_into_file, consider what happens if someone runs it twice. Use the :force option or add a guard that checks whether the content already exists before inserting.
  5. Test early. Generator bugs are tedious to debug manually because you have to clean up generated files between each attempt. Writing a test case first saves significant time, especially when you are iterating on templates.
  6. Consider overriding built-in generators. If you want every model in your application to use your custom template, you can override the built-in model generator by placing your generator at lib/generators/active_record/model/model_generator.rb. This replaces the default behavior for all rails generate model invocations. Use this approach carefully.

Further Reading

The official Rails guide on generators covers the full API, including generator lookup rules, fallbacks, and customizing scaffold generators. The Thor actions documentation is the reference for all file manipulation methods available inside generators. And the source code for Rails' built-in generators is a useful reference for how Rails itself structures more complex generators.

If you are building a Rails application and want help with architecture, tooling, or development workflows, we can help. Learn more about our web development services or get in touch to talk about your project.

Related Posts

AWS logo centered over dark blue stylized map of Europe with concentric radar-style rings emanating from Germany, representing the AWS European Sovereign Cloud infrastructure launch for EU data sovereignty and GDPR compliance
January 26, 2026 • Frank Valcarcel

AWS Launches European Sovereign Cloud

AWS launched a physically separate cloud infrastructure in Europe with EU-only governance, zero US dependencies, and over 90 services. Here is what organizations in healthcare, finance, and government need to know about the sovereign cloud and how to evaluate it for their compliance strategy.

Git Flow branching diagram showing colorful merge lines with feature branches, hotfix workflows, and release commits on dark purple background for release-it plugin automation
April 28, 2025 • Frank Valcarcel

Git Flow Releases with ­­release-it

Learn how to build a custom release-it plugin that automates your entire Git Flow workflow: version bumps, branch merges, multi-branch pushes, and GitHub releases with a single command.

Let's work together

Tell us about your project and how Cuttlesoft can help. Schedule a consultation with one of our experts today.

Contact Us