AI News Hub Logo

AI News Hub

Generators are APIs — Designing Better DX in Rails

DEV Community
RPA

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: jet_ui, a Rails gem I built at JetRockets and launched at Tropical on Rails 2026. 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 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. 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. 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. 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. 🔎 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: --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! 🚀 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