DEV Community

Cover image for Generators are APIs — Designing Better DX in Rails
RPA
RPA

Posted on

Generators are APIs — Designing Better DX in Rails

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Running this produces:

$ rails g jet_ui:eject btn

      create  app/components/jet_ui/btn/component.rb
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 btn learns 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 --help output 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 desc block 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
Enter fullscreen mode Exit fullscreen mode

Top comments (0)