Skip to content
Rotwise
Findings7 min read

Ten debt patterns in AI-generated Rails apps

Ten patterns that show up in nearly every Rails app written with Cursor or Claude Code, ranked by how much they cost you, each with the idiomatic Rails fix.

Rails is one of the best stacks for building with an assistant, because the conventions are strong enough that generated code usually lands in the right file. It is also where we see the most consistent debt, because the assistant knows just enough Rails to produce something that runs and not quite enough to produce something that lasts.

These are the ten patterns that appear in nearly every Rails app written with Cursor or Claude Code, ranked by how much they cost you, each with the idiomatic fix.

1. Request params straight into the model

User.create(params[:user])

The most expensive line in the list. Whoever posts the form can set any column, including admin. Strong parameters exist for exactly this and cost one line.

User.create(params.expect(user: [:name, :email]))

2. SQL built with interpolation

Order.where("status = '#{params[:status]}'")

Correct for every value except the one an attacker sends. Hash conditions or placeholders bind the value and cost nothing.

Order.where(status: params[:status])

3. A controller with no authentication

class OrdersController < ApplicationController
  def index
    @orders = Order.all
  end
end

The assistant wrote the action it was asked for and nothing about who may call it. Put before_action :authenticate_user! in ApplicationController, skip it by name on the handful of public controllers, and scope the query to the signed-in user: current_user.orders, never Order.all.

4. The bare rescue

rescue => error
  nil
end

A failing charge disappears, the order is marked paid, and the bug surfaces as a support ticket three services away. Rescue the narrowest class you can name, log with enough context to find the record, and let everything else raise.

rescue Stripe::CardError => error
  order.mark_declined!(reason: error.message)
end

5. The query inside the loop

@orders.each { |order| order.customer.name }

One query for the orders and one more per order. Invisible with ten rows, twelve seconds with ten thousand. Preload the association up front with includes(:customer) and turn on strict_loading in development so the next one raises instead of hiding.

6. Foreign keys without indexes

Generated migrations add customer_id columns and forget the index, because the prompt was about the model, not the query plan. Every lookup by that column becomes a table scan as the table grows. Use t.references :customer, which indexes by default, and audit existing tables once with a query against pg_indexes.

7. HTTP calls inside callbacks

after_create :notify_slack

A synchronous request to a third party inside a database transaction. When Slack is slow, sign-up is slow; when Slack is down, sign-up fails. Move the call into a job and enqueue it from after_create_commit, so the record exists before the job runs and a slow API never blocks a user.

8. Service objects that are all different

Ask for "a service to do X" ten times and you get ten shapes: some with call, some with perform, some class methods, some instances, three of them wrapping the same HTTP client. Pick one shape, one directory per role, one class per file, and the next prompt will follow the pattern it sees.

9. No tests where money lives

The payments controller, the webhook handler and the session code are the files most likely to have zero tests, because the prompts that produced them never mentioned testing. Before refactoring any of them, write a request spec that pins what they do today. It takes an hour and it is the only safety net a generated codebase has.

10. Nothing pinned

No .ruby-version, no ruby line in the Gemfile, a lockfile from six months ago. Every machine picks its own runtime and hits its own bugs. Pin the Ruby version, run bundle audit in CI, and treat a dependency with a known vulnerability as a P1 finding, because that is what an attacker treats it as.

The pattern behind the patterns

Every item above is the shortest correct answer to a feature request. The assistant did what it was asked. The fixes are all conventional Rails, which is the good news: nothing here needs a rewrite, and most of it is one line per occurrence. The first three items are security and belong in this week's pull request. The rest can follow one small batch at a time, in the order above, while you keep shipping.

Rotwise has a rule for each of these ten, and confirms the security ones against the surrounding files before they count, so a controller that inherits its authentication from a base class is not flagged twice. If you prefer to run the audit by hand, this list is the order to run it in.

← All posts