Your AI has no memory. Your Rails codebase does.

Your AI has no memory. Your Rails codebase does.

In Memento, Leonard wakes up every morning with no memory of yesterday. He doesn't know where he is. He doesn't know what he did. The only things he trusts are the tattoos on his body.

AI coding works the same way. Every session is a fresh wakeup in a motel room. It doesn't remember the raw SQL disaster from last session. It doesn't remember hallucinating a method yesterday. The only things it trusts are the rules you load into context.

And your codebase won't help. It remembers everything, including the bad habits. Every shortcut, every model that was never refactored, every raw SQL query nobody got around to fixing. AI treats all of it as truth. It doesn't know which patterns are current and which are legacy. It just sees code and reproduces it.

Conventions are the filter. They tell AI which patterns to trust.

I covered how to tattoo in the first post - skills and hooks that force AI to load conventions before writing code. Now the question is: what do you tattoo?

You wouldn't tattoo "use 2-space indentation." You'd tattoo the rules that prevent the worst mistakes. "Business logic lives in models." "Ask objects, don't reach into their internals." "No JavaScript if Turbo handles it."

If the tattoos are wrong, missing, or ambiguous, the AI goes off the rails. It doesn't have the context to question them.

These are my most important tattoos.

Ask objects, don't reach into their internals

I ask Claude to build a bookmarking feature. It creates the model:

# app/models/academy_bookmark.rb
class AcademyBookmark < ApplicationRecord
  belongs_to :user
  belongs_to :academy
end

That's it. Two associations. No methods. Where did the logic go?

# It's in the controller
current_user.academy_bookmarks.find_or_create_by(academy: @academy)

# It's in another controller
current_user.academy_bookmarks.find_by(academy: @academy)&.destroy

# It's in a job
User.find_each { |u| u.academy_bookmarks.where(created_at: ..1.week.ago).destroy_all }
  
# It's in the view
<% if current_user.academy_bookmarks.exists?(academy: academy) %>

The model is just a database table with a class name. The logic is scattered across four files and is playing hide-and-seek.

Leaking implementation turns every caller into a co-maintainer of your internals. That's bad enough with developers who at least understood the internals when they wrote the code. It's worse with AI, which doesn't understand them at all. It just confidently reproduces whatever it saw last.

When a coworker writes something confusing, you can hit them with the beloved "quick call?" and ask what they meant. When AI reproduces a raw association query it found somewhere in your codebase, there's no one to call.

Rails Model Conventions Skill

- Business logic lives here - Models own ALL domain logic, not controllers
- Clean interfaces - Don't leak implementation details
- Message passing - Ask objects, don't reach into their associations
- Pass objects, not IDs - Method signatures should accept domain objects

Tattoo made. Let's ask Claude again, same feature.

# app/models/academy_bookmark.rb
class AcademyBookmark < ApplicationRecord
  belongs_to :user
  belongs_to :academy
end

# app/models/user.rb
class User < ApplicationRecord
  def bookmarked?(academy)
    academy_bookmarks.exists?(academy: academy)
  end

  def bookmark(academy)
    academy_bookmarks.find_or_create_by(academy: academy)
  end

  def unbookmark(academy)
    academy_bookmarks.find_by(academy: academy)&.destroy
  end
end

# In the controller
current_user.bookmark(@academy)

# In another controller
current_user.unbookmark(@academy)

# In a job
User.find_each { |u| u.cleanup_old_bookmarks }

# In the view
<% if current_user.bookmarked?(academy) %>

Four files still use bookmarks. But none of them know how bookmarks work. Neat!

Over the past few years, I have been talking about this non-stop. This is the rule I'd save if I could only keep one. For developers and for AI. Business logic goes to models, which have clean interfaces and pass messages. You'll see this rule repeated across several layers. That's intentional.

I could have tattooed it on my forehead.

Full skill: https://github.com/marostr/superpowers-rails/blob/main/skills/rails-model-conventions/SKILL.md

Boring is correct

Hey Claude... Thinking about it, I only want students to be able to bookmark an academy. Also, I want them to unbookmark it. And I want them to be able to list all bookmarked academies. Can you do that for me?

Yes. I'd just do it, Marcin.
class AcademiesController < ApplicationController
  def bookmark
    @academy = Academy.find(params[:id])
    unless current_user.student?
      redirect_to @academy, alert: "Not authorized"
      return
    end
    current_user.academy_bookmarks.find_or_create_by(academy: @academy)
    redirect_to @academy, notice: "Bookmarked"
  end

  def unbookmark
    @academy = Academy.find(params[:id])
    unless current_user.student?
      redirect_to @academy, alert: "Not authorized"
      return
    end
    current_user.academy_bookmarks.find_by(academy: @academy)&.destroy
    redirect_to @academy, notice: "Removed"
  end

  def bookmarked_academies
    unless current_user.student?
      redirect_to root_path, alert: "Not authorized"
      return
    end
    @academies = Academy.joins(:academy_bookmarks)
                        .where(academy_bookmarks: { user_id: current_user.id })
                        .order(created_at: :desc)
  end
end

Custom actions instead of RESTful resources. Business logic in the controller. Inline authorization duplicated in every action. The controller knows how bookmarks work, how to check permissions, and how to serialize data. It shouldn't know any of these things.

The moment it does, every controller in your app becomes a unique snowflake. AI sees one controller checking current_user.student?, another checking current_user.moderator?, a third with no authorization at all. Which pattern does it follow for the next feature? It picks one.

Embrace the boring Rails. Use thin controllers that delegate to models and focus on request handling. Mandatory authorization adds minimal overhead and greatly improves your app security. Even if you don't have complex roles at the moment, you can easily introduce them later.

Rails Controller Conventions Skill

- Thin Controllers: No business logic - delegate to models
- Request Handling: Process parameters, handle formats, manage responses
- Authorization: Every action MUST call `authorize` - no exceptions
- RESTful: Stick to 7 standard actions, one controller per resource
- CRUD for state: Use `resource :closure` not `post :close` - create enables, destroy disables

Tattoo made. Let's ask Claude to refactor the feature.

class AcademyBookmarksController < ApplicationController
  def create
    @academy = Academy.find(params[:academy_id])
    authorize @academy, :bookmark?
    current_user.bookmark(@academy)
    redirect_to @academy
  end

  def destroy
    @academy = Academy.find(params[:academy_id])
    authorize @academy, :unbookmark?
    current_user.unbookmark(@academy)
    redirect_to @academy
  end

  def index
    authorize Academy, :bookmarks?
    @academies = current_user.bookmarked_academies
  end
end

One controller per resource. Every action is three lines: find, authorize, delegate. The controller doesn't know what bookmarking means, the model does. The controller doesn't know who's allowed, the policy does.

The best controller is a boring controller. It receives a request, checks authorization, asks a model to do something, and renders the result. If your controller is interesting, you've put the logic in the wrong place. And when AI is writing code, convention is exactly what you want - there's nothing to get creative with.

Full skill: https://github.com/marostr/superpowers-rails/blob/main/skills/rails-controller-conventions/SKILL.md

Contain the view mess

Hey Claude... One more thing. I want the bookmark button to update without a page refresh. When I click Bookmark, it should switch to Unbookmark. Live.

Easy.
<% if current_user.academy_bookmarks.exists?(academy: academy) %>
  <button class="btn btn-danger" data-controller="bookmark" 
    data-action="click->bookmark#remove"
    data-bookmark-id-value="<%= current_user.academy_bookmarks.find_by(academy: academy).id %>">
    Unbookmark
  </button>
<% else %>
  <button class="btn btn-primary" data-controller="bookmark"
    data-action="click->bookmark#add"  
    data-bookmark-academy-id-value="<%= academy.id %>">
    Bookmark
  </button>
<% end %>

<span id="bookmark-count">Loading...</span>
export default class extends Controller {
  static values = { id: Number, academyId: Number }

  async add() {
    const response = await fetch(`/api/academies/${this.academyIdValue}/bookmarks`, {
      method: "POST",
      headers: { "Content-Type": "application/json",
        "X-CSRF-Token": document.querySelector('meta[name="csrf-token"]').content }
    })
    const data = await response.json()
    document.getElementById("bookmark-count").textContent = `${data.count} bookmarks`
    this.element.textContent = "Unbookmark"
    this.element.classList.replace("btn-primary", "btn-danger")
  }

  async remove() {
    const response = await fetch(`/api/bookmarks/${this.idValue}`, {
      method: "DELETE",
      headers: { "X-CSRF-Token": document.querySelector('meta[name="csrf-token"]').content }
    })
    const data = await response.json()
    document.getElementById("bookmark-count").textContent = `${data.count} bookmarks`
    this.element.textContent = "Bookmark"
    this.element.classList.replace("btn-danger", "btn-primary")
  }
}

Well, technically... it works. But.

Logic in the view. Reaching into associations. JSON API instead of Turbo. Manual DOM manipulation. The ERB logic can't be unit tested. The Stimulus controller needs a full browser to verify. When AI hallucinates a method in that template, nothing catches it until someone opens a browser and clicks the button.

Rails View Conventions Skill

- Hotwire/Turbo - Use Turbo frames for dynamic updates, never JSON APIs
- ViewComponents for logic - All presentation logic in components, NOT helpers
- NO custom helpers - `app/helpers/` is prohibited. Use ViewComponents instead
- Dumb views - No complex logic in ERB, delegate to models or components
- Stimulus for JS - All JavaScript through Stimulus controllers
- Don't duplicate model logic - If a model has a method, delegate to it; don't reimplement the logic in the component

Tattoo made. Let's ask Claude to refactor the feature.

<%# app/views/academies/show.html.erb %>
<%= turbo_frame_tag dom_id(academy, :bookmark) do %>
  <%= render(BookmarkButtonComponent.new(user: current_user, academy: academy)) %>
<% end %>
# app/components/bookmark_button_component.rb
class BookmarkButtonComponent < ViewComponent::Base
  def initialize(user:, academy:)
    @user = user
    @academy = academy
  end

  def bookmarked?
    @user.bookmarked?(@academy)
  end
end
<%# app/components/bookmark_button_component.html.erb %>
<% if bookmarked? %>
  <%= button_to "Unbookmark", academy_bookmark_path(@academy), method: :delete %>
<% else %>
  <%= button_to "Bookmark", academy_bookmarks_path(academy_id: @academy.id) %>
<% end %>
<%# app/views/academy_bookmarks/create.turbo_stream.erb %>
<%= turbo_stream.replace dom_id(@academy, :bookmark) do %>
  <%= render(BookmarkButtonComponent.new(user: current_user, academy: @academy)) %>
<% end %>

No JavaScript. No JSON API. No manual DOM manipulation. The button submits a form, the server responds with a Turbo Stream that replaces the component. The view is just glue.

Logic extracted into ViewComponents can easily be tested. Combine it with undercover gem and you get all presentation logic and edge cases unit tested. No more NoMethodError: undefined method 'hallucinated_method' slipping through, the component spec will catch it before it reaches production. More on that in a previous blog post.

Models vs ViewComponents: Models answer domain questions ("what is the deadline?"). ViewComponents answer presentation questions ("how do we display it?" - colors, icons, formatting).

And yes, ViewComponents will accumulate tech debt, with AI coding everything does. But tech debt in a named, testable, isolated box is a night and day difference. You can grep for it, test it, refactor it in isolation. You can even tell AI agent "clean up these components" and it has everything it needs in one place. ViewComponents don't eliminate tech debt, they contain it.

Full skill: https://github.com/marostr/superpowers-rails/blob/main/skills/rails-view-conventions/SKILL.md
https://github.com/marostr/superpowers-rails/blob/main/skills/rails-stimulus-conventions/SKILL.md

Other conventions

So far I've covered MVC. The full set includes:

  • Testing: RSpec structure, factory patterns, what to mock and what not to.
  • Background Jobs: idempotency, thin jobs, let errors raise.
  • Policies: check WHO, not WHAT or WHEN.
  • Migrations: always reversible, index every foreign key, follows strong_migrations.

All in the skills repo.

Wrap up

Leonard tattooed universal truths on his body. "Don't trust Teddy." "Remember Sammy Jankis." He also carried Polaroids. These were context-specific notes about people, places, and situations that changed as his investigation progressed.

Your Rails conventions are like tattoos. They work for any project. Business logic is in models. Controllers are kept thin. Use ViewComponents instead of helpers.

However, every project has its own unique characteristics. Maybe your team chose React for the frontend, and AI needs to build API endpoints instead of server-rendered views. Maybe you're using microservices, and controllers must never query another service's database. Maybe your team chose CQRS, so reads follow a different path than writes. Write these details down as skills too.

Tattoos get you 80% of the way there. Polaroids help you finish the rest.


On this blog I plan to cover the entire autonomous AI coding journey.

So far, we have covered:

I'd love to hear your thoughts. Reach out to me on LinkedIn or at marcin@fryga.io.