Architecture

Table of contents
  1. The shape
  2. One run of new
  3. Key classes
  4. Core versus add-on
  5. Why the finishing steps exist
  6. Editing Rails’ generated files

The shape

The gem is a Thor CLI (exe/opinionated_rails, lib/opinionated_rails/cli.rb). new runs rails new, then a set of steps: small classes, each owning one option, that prompt, edit the generated app, and record what they did.

lib/opinionated_rails/
  cli.rb                     the commands and the run's orchestration
  answer_shell.rb            answers prompts from flags/config; validates values
  context.rb                 shared state for one run
  step.rb, step_graph.rb     the step contract and dependency ordering
  prerequisites.rb           tools the chosen options need (yarn, bun, node)
  rails_version_guard.rb     Rails 8.1+ check
  kamal_config.rb            builds config/deploy.yml and .kamal/secrets
  pins.rb                    inventory and registry check of pinned versions
  generators/                rails_new_invoker.rb, file_writer.rb
  steps/core/                steps asked before `rails new`
  steps/addons/              steps that run afterwards
  templates/                 static files copied into generated apps

One run of new

CLI#new in order:

  1. RailsVersionGuard.check_installed! reads rails --version and rejects anything but 8.1+ within 8.x.
  2. Answers. preset_answers merges --config (YAML), then --database and --javascript; the APP_NAME argument overrides app_name. AnswerShell wraps the Thor shell with them. Its constructor validates every key and value (LABELS, CHOICES), so mistakes fail before anything is generated.
  3. Core prompts run through prompt_and_generate: AppNameStep, DatabaseStep, AppTypeStep, then TestingStep and FrontendStep (prompt only). The directory must not already exist.
  4. Prerequisites.check! verifies yarn or bun for React.
  5. RailsNewInvoker runs rails new with flags built from the answers (--database, --api, --skip-test, --javascript, and always --skip-docker --skip-kamal --skip-ci, because the matching steps write better files).
  6. StepGraph sorts CLI.generated_steps by their needs; each step is prompted (if it has not been) and generated by run_step. An exception is wrapped with the step name and the app path.
  7. finish_app: final bundle install, JavaScript install and builds, Tailwind build, RuboCop autocorrect.
  8. Unused config keys are reported.

Key classes

Step (step.rb): the base class. Class methods id, tier, needs, summary, description, prompts; instance methods if_condition(context), prompt(context), generate(context), outputs(context). See Writing a step.

Context (context.rb): state for one run. answer(key, value) / get(key) hold what the user chose; set_output(step_id, hash) / output(step_id, key) hold what steps produced, which later steps read (for example context.output(:database, :database)). It also carries app_path, shell and skip_bundle_install.

StepGraph (step_graph.rb): a topological sort (Kahn’s algorithm) over needs. It raises MissingDependencyError if a needed step is not registered, and CycleError for cycles, naming the steps involved.

AnswerShell (answer_shell.rb): the object steps call ask on. It answers by matching the prompt’s label (the text before (default) to a config key, returns "" (the default) under --yes, and otherwise delegates to the real shell. If a step re-asks a preset question (meaning it rejected the value), it raises instead of looping. LABELS and CHOICES are the single list of config keys and accepted values; answer_choices_spec.rb proves every choice is accepted by the step that asks for it.

KamalConfig (kamal_config.rb): pure functions that return the text of config/deploy.yml and .kamal/secrets for a database and stack. DockerKamalStep writes them after kamal init.

Prerequisites and RailsVersionGuard: preflight checks that fail before anything is created.

Pins (pins.rb): finds every pinned gem and npm version in the steps and checks each exists on its registry (rake pins:check). A new *_VERSION constant must be mapped to a gem name in Pins::GEM_NAMES or the spec fails.

Core versus add-on

Steps::Core (app_name, database, app_type, testing, frontend) exists because CLI#new must know these before rails new runs: they become rails new flags. A step belongs in core only if something structural depends on it running before rails new. Everything else is an add-on that declares needs.

Why the finishing steps exist

Steps append gems to the Gemfile and packages to package.json after the last install that Rails or a generator ran. Without a final install and build the app would not boot, or the first page would fail on missing assets. The finishing steps are not cosmetic; the real-app matrix covers each.

Editing Rails’ generated files

Steps patch text Rails generates (production.rb, cable.yml, …). Substituting a string that is absent silently does nothing, which leaves an app half-configured with no sign of it. The rule: warn through context.shell.say("Warning: ...") and include the exact setting to apply by hand. See solid_stack_step.rb and mailer_step.rb for the pattern.


Back to top

Released under the MIT License.