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
endRunning 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.ttThe 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 -%>
endThe 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
endAnd 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
endThe 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
endThe 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
endThe 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
endRunning 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
endAnd 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
endGenerator 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
- Start with
create_file, then move to templates. A heredoc string is fine for prototyping. Once the output is stable, move it into a.tttemplate for readability and maintainability. - Use
NamedBasewhen you need a name. It gives youclass_name,file_path,file_name,singular_name,plural_name, and other inflection helpers for free. Only useBasewhen the generator truly does not need a name argument. - Keep templates in the same directory as the generator. The conventional structure is
lib/generators/your_generator/with atemplates/subdirectory. This keeps everything colocated and easy to find. - Make file-editing idempotent. If your generator uses
insert_into_file, consider what happens if someone runs it twice. Use the:forceoption or add a guard that checks whether the content already exists before inserting. - 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.
- 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 allrails generate modelinvocations. 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.


