This article is based on the talk I gave at Tropical on Rails 2026 in São Paulo. If you were there, this is the written version. If you weren't — welcome. This is the whole thing.
Introduction:
Rails generators are one of the most underrated tools in the framework. Every Rails developer has used them. Few have designed them. And almost nobody treats them as what they actually are: an API. This article makes the case that generators deserve the same design attention we give to REST APIs — and shows what that looks like in practice, using real code from jet_ui, a Rails gem I built at JetRockets and launched at Tropical on Rails 2026.
The Wrong Frame
The typical mental model for generators looks like this:
Input a name → Get some files → Move on with your life
It's a reasonable model. Generators are code generation tools — you give them a name, they give you files, you keep building. Nobody questions this because it works. rails g model User creates a model. rails g controller Posts creates a controller. The generator runs, the files appear, and the developer moves on.
But that frame misses something important. Every time a developer runs a generator in a project, they're not just running a command. They're accepting a decision that someone else made. A decision about folder structure, naming conventions, what gets generated by default, what doesn't, and what happens when something goes wrong.
If that decision was made well, the developer doesn't notice — they just get good results. If it wasn't, they get confusion, inconsistency, and eventually a Slack message that reads: "Hey, how do we do X here?"
That's not automation. That's influence. That's design.
The API Analogy
The reason generators deserve more attention is that they share the same interface contract as an API. Consider the mapping:
| API concept | Generator equivalent |
|---|---|
| Endpoint name | Generator name |
| Request params | Arguments & flags |
| Response body | Generated files |
| Error messages | Failure output |
We hold APIs to a high standard because we know developers are going to interact with them repeatedly. We think carefully about naming, document every parameter, return consistent responses, and write error messages that explain what went wrong and how to fix it. If an API has confusing naming, we file a bug. If inputs are unpredictable, we complain. If errors are useless, we write angry posts on X.
And then we ship a custom generator with no --help, no output messages, and a Ruby backtrace on failure — and nobody bats an eye.
The difference isn't that APIs matter more. The difference is that we never thought of generators as something worth designing. This article argues that we should.
What Bad DX Looks Like
To make this concrete, here are three real problems that show up in poorly designed generators. The examples use jet_ui as the reference context — but to be clear, these are illustrative scenarios, not actual issues in the gem. Think of them as "what jet_ui:eject could have looked like if DX hadn't been a priority from the start."
- 🔴 Problem 1: Ambiguity
When a generator has no documented naming convention, developers are left guessing. Should the component name be capitalized? Abbreviated? Written as a path?
$ rails g jet_ui:eject Button
$ rails g jet_ui:eject button
$ rails g jet_ui:eject btn
All three feel plausible. Without documentation or a clear convention communicated through the generator itself, a new developer has no way to know which form is correct — or whether it even matters. This is a naming contract that was never written. The cost isn't just confusion on day one. It's inconsistency across the entire codebase as different developers pick different forms over time.
- 🔴 Problem 2: No feedback
A generator that runs silently is a generator that communicates nothing. Here's what an eject_components method looks like without any output calls — just the bare template loop:
def eject_components
components.map(&:downcase).each do |name|
MANIFEST[name][:files].each do |entry|
template entry[:src], entry[:dest]
end
end
end
Running this produces:
$ rails g jet_ui:eject btn
create app/components/jet_ui/btn/component.rb
One file created. No CSS. No test. No preview. No confirmation that the operation was successful. No mention of what was skipped or why. And critically — no next steps. The developer is left wondering: are there other files I need to create manually? Do I need to restart the server? Do I need to run anything?
Good tools tell you what they did. Great tools tell you what to do next.
- 🔴 Problem 3: No control by having no options
A generator that always generates everything — regardless of the project's needs — forces developers to either accept files they don't want or manually delete them after every run. The problem isn't the output itself, it's the absence of choice.
class EjectGenerator < Rails::Generators::Base
argument :components, type: :array,
banner: "component [component ...]",
desc: "Component(s) to eject (e.g. btn card)"
# No class_option :skip_test
# No class_option :skip_preview
end
With this implementation, generating a component always produces the component file, the CSS file, the test file, and the preview file. Every time. Without exception.
But what if the project uses RSpec instead of Minitest? What if previews aren't part of the workflow? What if the developer only needs the CSS to override a style? None of that matters — the generator decided for them, and gave them no voice in the matter.
Five Principles for Better DX
These three problems have the same root cause: the generator was written, not designed. Writing a generator means producing something that works. Designing one means producing something that communicates, guides, and respects the developer using it. Here's what that looks like across five principles.
- 🟢 Principle 1: Naming is a contract
The name of a generator, and the names of its arguments, should communicate exactly what it does and how to use it. This isn't about being clever or descriptive — it's about being unambiguous. A developer should be able to read the command and know what they'll get before they run it.
The desc block and the argument definition are the primary tools for this. They're not just documentation — they're what makes --help work, and --help is the first thing a developer reaches for when something isn't obvious.
class EjectGenerator < Rails::Generators::Base
desc "Ejects JetUi component(s) into your application for local customisation."
argument :components, type: :array,
banner: "component [component ...]",
desc: "Component(s) to eject (e.g. btn card)"
end
With this in place, --help produces something actually useful:
$ rails g jet_ui:eject --help
Usage:
rails generate jet_ui:eject component [component ...] [options]
Description:
Ejects JetUi component(s) into your application for local customisation.
Example:
rails g jet_ui:eject btn card
The naming convention — use the component's short name — is now communicated through the generator itself. No Slack message required.
- 🟢 Principle 2: Predictable inputs
Every flag a generator accepts is a promise to the developer. It says: this is a choice you can make, here's what it controls, and here's what happens by default. When flags are undocumented or absent, developers either don't know the choice exists or can't make it.
The class_option method is where this happens. Each option should have a desc that explains what it does in plain language:
class_option :skip_test, type: :boolean,
default: false,
desc: "Skip test files for each component"
class_option :skip_preview, type: :boolean,
default: false,
desc: "Skip preview files for each component"
This solves the "no control" problem immediately. Running rails g jet_ui:eject btn --skip-test skips the test files. Running it with --skip-preview skips the previews. The defaults still generate everything for developers who want the full output. Everyone gets what they need without fighting the tool.
Predictable inputs also make generators easier to automate, script, and integrate into CI pipelines — because the behavior is explicit, not assumed.
- 🟢 Principle 3: Transparent output
A generator that says nothing is a black box. The developer runs it, files appear, and they have to manually inspect the result to understand what happened. This is fine for simple cases — but it doesn't scale. As generators get more complex, silent execution becomes a liability.
The say and say_status methods exist precisely to address this. They're underused in most custom generators, but they're what separates a tool that communicates from one that doesn't.
def eject_components
# ... validation + template logic
say "\nEjecting #{name}...", :cyan
# ... generate files
say " #{name} ejected.", :green
end
def show_summary
say "\nDone! Ejected: #{components.map(&:downcase).join(", ")}\n", :green
say "The local files in app/components/jet_ui/ now take precedence over the gem."
say "Run your tests to confirm everything still works:\n"
say " bundle exec rake test\n"
end
This is the real output from jet_ui:eject:
Ejecting btn...
create app/components/jet_ui/btn/component.rb
create app/assets/stylesheets/jet_ui/btn.css
create test/components/jet_ui/btn/component_test.rb
create test/components/previews/jet_ui/btn/component_preview.rb
btn ejected.
Done! Ejected: btn
The local files in app/components/jet_ui/ now take precedence over the gem.
Run your tests to confirm everything still works:
bundle exec rake test
Every line is a deliberate decision. What to announce before acting. What to confirm after each file. What to summarize at the end. What to tell the developer to do next. That last part — the next steps — is the most commonly missing piece in generator output. It's also the one that most directly reduces the number of questions a new developer has to ask.
- 🟢 Principle 4: Helpful errors
An error message that doesn't explain the problem isn't a message — it's noise. The worst case is a Ruby backtrace: technically accurate, completely useless to the developer who just mistyped a component name. The better approach is to catch the problem early, name it clearly, and show what's valid.
def eject_components
unknown = components.map(&:downcase) - MANIFEST.keys
if unknown.any?
say "\nUnknown component(s): #{unknown.join(", ")}", :red
say "Available: #{MANIFEST.keys.join(", ")}\n", :red
exit 1
end
# ... rest of the logic
end
The result is a message that tells the developer exactly what went wrong and what to do about it:
$ rails g jet_ui:eject buton
Unknown component(s): buton
Available: btn, card
This pattern — validate early, fail loudly, show the valid options — applies to any generator that accepts constrained input. It costs very little to implement and dramatically reduces the friction of a first failure.
- 🟢 Principle 5: Design for extension
A generator that can only be used as-is — with no way to extend or modify its behavior without touching the source — is fragile. It forces developers to choose between using it as prescribed or forking it entirely. Neither option is good.
Rails generators are built on Thor, which means they inherit from Rails::Generators::Base — and that means subclassing is a first-class pattern. A well-designed generator takes advantage of this by organizing its logic into small, focused methods that can be overridden or extended individually.
class EjectWithStorybookGenerator < JetUi::Generators::EjectGenerator
desc "Like jet_ui:eject, but also generates Storybook stories."
def generate_stories
components.map(&:downcase).each do |name|
template "#{name}/story.js.tt",
"stories/jet_ui/#{name}/component.stories.js"
end
end
end
This subclass gets everything from the parent for free — argument handling, validation, say calls, summary output — and adds exactly one new behavior. The original generator is never modified. The team member who wrote this extension never had to ask how the base generator works internally — they just used it as a foundation.
If a generator can't be extended without modifying it, it's not a tool. It's a script.
The Bigger Picture
These five principles are tactical. But there's a strategic argument behind them that goes beyond individual generators.
Every time a developer runs a generator, they're executing a decision that was made when it was designed. If that decision was made well — with clear naming, predictable inputs, transparent output, useful errors, and extensibility in mind — it scales. It scales to every developer on the team, every feature they build, every project that follows the same conventions. The generator becomes a living encoding of architectural decisions that would otherwise live only in documentation, tribal knowledge, or the memory of whoever wrote the original code.
If the decision wasn't made well, that also scales. It scales the inconsistency, the confusion, and the technical debt — one run at a time.
This is why generators matter beyond code generation. A well-designed generator is:
- 📋 Onboarding — a new developer who runs
rails g jet_ui:eject btnlearns the project's conventions by using them, not by asking. The output tells them what was created, what takes precedence, and what to run next. That's an onboarding experience encoded in a command. - 📖 Living documentation — the
--helpoutput describes exactly what the generator does and how to use it. Unlike a README, it can't go out of date — it's generated from the same code it describes. - 🏗 Architecture enforcement — conventions encoded in generators are conventions that get followed. Not because developers are disciplined, but because the path of least resistance points in the right direction.
Three Things to Do Tomorrow
🔎 Audit one generator in your project as if it were a public API. Run it with
--help. Is the output useful? Are the flags documented? Are the error messages actionable? Would you ship this to external developers?⚡ Add a
descblock to any custom generator that doesn't have one. One line. Ten minutes. Your generator just got a help page, and every developer on your team can now discover what it does without reading source code.💡 Next time you write a generator, ask yourself: could a new developer use this without asking me anything? That question alone — applied honestly — captures everything in this article.
Conclusion:
There's a quiet irony in how we build software. We spend hours debating API design, writing documentation, reviewing pull requests — all in the name of quality. And then we ship a custom generator with no --help, no feedback, and no error messages, and nobody bats an eye.
Generators are invisible infrastructure. They run once, they generate files, and then they disappear from the conversation. But the decisions they encode — the folder structures, the naming conventions, the assumptions about what a developer needs — those stay. They get repeated. They get inherited by every new team member, every new feature, every new project that follows the same pattern.
That's why this matters. Not because generators are complex or glamorous, but because they're mundane. The tools we use every day without thinking are exactly the ones that shape how we think. Design them with intention, and that intention scales silently across your entire team.
The next time you write a generator, treat it like a public API. Because for the developers who will use it — it is.
Happy coding! 🚀
About jet_ui
jet_ui is a Rails gem I built at JetRockets to package the UI component library originally created by Aleksei Solilin — a comprehensive collection of ViewComponent, TailwindCSS 4.0, and Stimulus components available at ui.jetrockets.com. The gem makes those components installable and ejectable in any Rails project, new or existing, without copying code manually.
The jet_ui:eject generator shown throughout this article is real, open source, and available for inspection at github.com/jetrockets/jet_ui.
To get started:
# Add to your Gemfile
gem 'jet_ui'
# Install
bundle install
# Run the install generator
rails g jet_ui:install
# Eject a component for local customisation
rails g jet_ui:eject btn
Top comments (0)