LaunchKit
← All posts
· 9 min read · by The LaunchKit team · 0 views

Rails MVP: the twenty-eight lines the generators skip

The two commands that start a Rails MVP take about ten seconds, and the app they produce cannot pass its own test suite and has no way for a stranger to become a user. Neither of those is a secret and neither is a bug, but both are work, and both are cheaper to do on Saturday morning than in week three. This page is the work.

What a generated app contains, how much memory it uses and how fast it serves a page is counted separately. Everything here starts from the point where rails new mvp -d postgresql --skip-ci, bin/rails generate authentication and one bin/rails generate scaffold have already run, on Rails 8.1.4 and Ruby 4.0.5, against PostgreSQL 17.7 on an Apple M2 Max.

The suite is red before you write a line

Three generators, no edits, then bin/rails test:

$ bin/rails generate scaffold Link title:string url:string clicks:integer
$ bin/rails db:migrate
$ bin/rails test
Running 19 tests in a single process (parallelization threshold is 50)
Run options: --seed 38647

# Running:

....E

Error:
SessionsControllerTest#test_create_with_valid_credentials:
NameError: undefined local variable or method 'root_url' for an instance of SessionsController
    app/controllers/concerns/authentication.rb:38:in 'Authentication#after_authentication_url'
    app/controllers/sessions_controller.rb:11:in 'SessionsController#create'

Finished in 1.027778s, 18.4865 runs/s, 53.5135 assertions/s.
19 runs, 55 assertions, 7 failures, 1 errors, 0 skips

Two separate causes, and the interesting thing about the first is that the generator wrote both halves of it. Line 38 of the concern it writes is session.delete(:return_to_after_authenticating) || root_url. Line 14 of the test it writes, in test/controllers/sessions_controller_test.rb, is assert_redirected_to root_path. And config/routes.rb, written by a different generator, ships # root "posts#index" commented out. So bin/rails generate authentication produces a test that cannot pass in the application it just generated. That the sign-in itself raises on the happy path is covered elsewhere; what matters here is that the suite is carrying a red mark from minute one, and a red suite you have learned to ignore is worth less than no suite.

The seven failures are the other half of include Authentication landing in ApplicationController. Every controller generated afterwards inherits before_action :require_authentication, and the scaffold generator does not know that, so all seven of its tests get a redirect where they expect a 200:

Failure:
LinksControllerTest#test_should_get_index [test/controllers/links_controller_test.rb:10]:
Expected response to be a <2XX: success>, but was a <302: Found> redirect to <http://www.example.com/session/new>

This is the failure that teaches people to delete generated tests, which is a bad habit to acquire in week one, because the fix is one line and the generator already wrote it for you. test/test_helpers/session_test_helper.rb defines sign_in_as(user) and sign_out, and ActiveSupport.on_load(:action_dispatch_integration_test) includes the module into every integration test. So:

class LinksControllerTest < ActionDispatch::IntegrationTest
  setup { sign_in_as users(:one) }

and the seven go green:

$ bin/rails test test/controllers/links_controller_test.rb
7 runs, 11 assertions, 0 failures, 0 errors, 0 skips

users(:one) is a fixture the authentication generator wrote too, in test/fixtures/users.yml, with password_digest computed once at the top of the file by <% password_digest = BCrypt::Password.create("password") %> rather than per record. Two users, one@example.com and two@example.com.

The cost of that setup line is real and worth naming: the scaffold tests now never exercise the signed-out case, so the page that is supposed to be public is the one nobody notices is not. A marketing page, a pricing page, a shared link. That is what allow_unauthenticated_access is for, and it belongs in the controller with a test asserting the 200, not hidden behind a sign_in_as that makes the red go away.

The twenty-eight lines

bin/rails generate authentication declares resource :session and resources :passwords, param: :token and nothing else. There is no users#create, no registrations controller, and no route that turns a visitor into a row in the users table. The generator's USAGE file, at railties-8.1.4/lib/rails/generators/rails/authentication/USAGE, is honest about it in one line:

Description:
    Generates a basic authentication system with users, sessions, and password reset.

"Users" there means the model. AuthenticationGenerator#create_authentication_files makes ten template calls and none of them is a registrations controller.

Here is the whole of what closes that. A controller:

class UsersController < ApplicationController
  allow_unauthenticated_access only: %i[ new create ]
  rate_limit to: 10, within: 3.minutes, only: :create

  def new
    @user = User.new
  end

  def create
    @user = User.new(params.expect(user: [ :email_address, :password, :password_confirmation ]))

    if @user.save
      start_new_session_for @user
      redirect_to after_authentication_url, notice: "Welcome."
    else
      render :new, status: :unprocessable_entity
    end
  end
end

A form at app/views/users/new.html.erb, nine lines, nothing in it you have not written before. resources :users, only: %i[ new create ] in the routes. And one line in the model:

validates :email_address, presence: true, uniqueness: true

Nineteen lines of controller, nine of ERB, two elsewhere. start_new_session_for and after_authentication_url are private methods on the concern the generator already gave you, which is why sign-up ends with the visitor signed in without a single line here about cookies. The rate_limit macro is the same one SessionsController and PasswordsController already use.

The validates line is the one to argue about, and leaving it out is the wrong turn. The generated migration is email_address:string!:uniq, which becomes add_index :users, :email_address, unique: true, and app/models/user.rb carries has_secure_password, has_many :sessions, dependent: :destroy, a normalizes call and no validation at all. A registration controller written against that model does not render an error on a duplicate address. It raises:

ActiveRecord::RecordNotUnique: PG::UniqueViolation: ERROR:  duplicate key value violates unique constraint "index_users_on_email_address"

which is a 500 on one of the first things a confused visitor does: signing up twice, because the first attempt did not obviously work. The validation costs one extra query per valid?:

User Exists? (0.9ms)  SELECT 1 AS one FROM "users" WHERE "users"."email_address" = 'probe@example.com' LIMIT 1 /*application='Mvp'*/

It still races under concurrency, and the unique index is what actually guarantees the constraint. You want both. The generator gives you one, and it is the one that produces a stack trace rather than a form error.

Three tests, run against all of the above:

$ bin/rails test test/integration/registration_test.rb
3 runs, 19 assertions, 0 failures, 0 errors, 0 skips

They assert that a sign-up creates one User and one Session and sets cookies[:session_id]; that New@Example.com is stored as new@example.com, because normalizes runs before the uniqueness check rather than after it; that a mismatched confirmation renders 422 carrying Password confirmation doesn&#39;t match Password, escaped, which is the form the assertion has to match and the one that costs ten minutes the first time; and that DUP@example.com with a trailing space is refused as a duplicate rather than raising. That last one is the assertion worth keeping, because it is what fails the day somebody removes normalizes or the validates line.

The dead end: rails new exits 0 on a broken app

Four of the five apps generated for this page came out like this, and the reason took a while to find:

       rails  importmap:install
/Users/mehdifarsi/.rvm/rubies/ruby-4.0.5/lib/ruby/4.0.0/bundled_gems.rb:60:in 'Kernel.require': cannot load such file -- bootsnap/setup (LoadError)
    from .../config/boot.rb:4:in '<top (required)>'
    from bin/rails:3:in '<main>'
       rails  turbo:install stimulus:install
...same...
       rails  solid_cache:install solid_queue:install solid_cable:install
...same...

Three failed generator steps, and echo $? printed 0. The resulting app has no config/importmap.rb, no app/javascript/, and none of db/queue_schema.rb, db/cache_schema.rb or db/cable_schema.rb. You find out later, when Hotwire does not load or db:prepare cannot build the queue database.

The cause is two ordinary lines. In railties-8.1.4/lib/rails/generators/actions.rb, execute_command ends on in_root { run("#{sudo}#{Shellwords.escape Gem.ruby} bin/#{executor} #{command}", config) }, and the config it passes sets abort_on_failure: options[:abort_on_failure]. None of the three installer calls passes that option, so it is nil and a non-zero child is ignored. And line 1 of the config/boot.rb the generator has just written is ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__), with ||=. Dumping the environment from inside the generator shows what that ||= is preserving:

BUNDLE_GEMFILE=/private/tmp/.../scratchpad/Gemfile
RUBYOPT=-r/Users/mehdifarsi/.rvm/rubies/ruby-4.0.5/lib/ruby/4.0.0/bundler/setup

These runs were from a directory sitting under another Ruby project. The rails binary had bundled itself against that enclosing Gemfile and exported both variables to every child process. ||= kept them. The new app's bin/rails therefore resolved against a Gemfile with no bootsnap in it and died on line 4 of its own boot.rb. Run the identical command from a directory with no Gemfile anywhere above it and the same rails new prints zero errors and produces all five: config/importmap.rb, app/javascript/, and the three schema files.

So rails new inside an existing Ruby project produces a silently incomplete application, and this is not an exotic place to be: a monorepo puts you there, and so does rails new ../mvp typed from inside the app you already have open. Generate somewhere clean, or check for config/importmap.rb before you do anything else.

The first half hour

Before the first feature: uncomment root, add sign_in_as to the generated controller tests, write the twenty-eight lines with the validates line in them, and get bin/rails test back to zero. On the app this page was written against that is:

$ bin/rails test
22 runs, 78 assertions, 0 failures, 0 errors, 0 skips

Half an hour, all of it mechanical, and each piece is a thing that looks finished and is not.

The position, and what would change it: bin/rails generate authentication should write a UsersController and uncomment root, or it should stop shipping a test that asserts a route it does not create. The argument against generating registration is that registration is where products differ most, and a generated one would be wrong for invite-only apps, for team sign-up, for anything with a waiting list. That argument is real, and it is not what happens in practice: what happens in practice is that every Rails 8 app grows the same nineteen lines. A --registration flag in the style of the existing --api flag would settle it, and so would writing root commented-in against the scaffold you are about to generate anyway. Either one and the complaint goes away.

What this page does not cover

The inventory. What rails new installs, how many gems, how much memory five processes hold and what one Puma worker serves per second is its own count, along with the password reset that fails silently in production and the routes resources :passwords declares for actions that do not exist.

Whether to use the generator at all. The case for Devise against the Rails 8 generator is a different post, and nothing here is an argument against Devise.

Email confirmation, which is a separate hole from registration: the twenty-eight lines above sign the visitor straight in without verifying that the address belongs to them, which is the right default for a product with no free tier to abuse and the wrong one the moment there is.

Billing, which is not a generator gap. There is no payment gem in a generated app and there was never going to be one.

#rails #saas #indie-hacking

Comments

No comments yet. Be the first.

Only used to confirm and publish your comment. Never shown publicly, never shared.

Markdown: **bold**, `code`, ```fenced blocks```, > quotes, [links](url). HTML and images are not rendered.