Skip to content

Instantly share code, notes, and snippets.

@palkan

palkan/AGENTS.md Secret

Forked from irinanazarova/AGENTS.md
Last active March 2, 2026 00:45
Show Gist options
  • Select an option

  • Save palkan/482010bd6ec434685f106779e863d0ef to your computer and use it in GitHub Desktop.

Select an option

Save palkan/482010bd6ec434685f106779e863d0ef to your computer and use it in GitHub Desktop.
Vibe coding with style

Disclaimer (REMOVE THIS LINE BEFORE ADDING THIS FILE TO YOUR PROJECT): to be clear, these are not the “official” Evil Martians Rails guidelines. They are imperfect by design, produced by a very simple process, from a single refactoring case. But it’s a good start, and already practical.

AGENTS.md: Rails Code Generation Standards

This document guides AI code generation for Rails applications built at Evil Martians. It's designed for founders, designers, and PMs who use Claude, GitHub Copilot, or other AI tools to build maintainable Rails code without needing a senior engineer to refactor everything.

Core Principle: Generated code should be so simple and clear that reading it feels like reading well-written documentation. Elegant. Self-explanatory.


Project Overview

We build Rails applications that prioritize simplicity, clarity, and maintainability. We trust Rails conventions rather than fighting them. We name things after business domains, not technical patterns. We avoid over-architecture.

Generated code should:

  • Follow Rails conventions (not fight the framework)
  • Use domain language (Participant, not User; Cloud, not GeneratedImage)
  • Keep logic at the right layer (models handle data, controllers handle HTTP, jobs coordinate workflows)
  • Be readable without comments
  • Normalize data properly (separate concerns into tables, not columns)

Technology Stack & Gems

Only use these gems. If you want to add something not listed, ask in a clarifying comment.

Core Rails & Server

  • rails (latest stable)
  • puma (default)
  • propshaft (asset pipeline)

Database & Data

  • activerecord (included)
  • pg (PostgreSQL)
  • store_model (type-safe JSON columns, when needed)

Frontend

  • hotwire-rails (Turbo + Stimulus, included)
  • view_component (reusable UI components)
  • vite_rails (modern JS/CSS bundling with hot reload)
  • bundlebun (Node-as-a-gem runtime)

Jobs & Background Work

  • solid_queue (default)

Authentication & Authorization

  • built-in authentication (generator)
  • omniauth (for OAuth)
  • action_policy (for complex authorization)

Configuration

  • anyway_config (type-safe configuration from environment)
  • Don't use: Rails credentials, ENV variables directly

Testing

  • rspec-rails (not minitest)
  • factory_bot_rails (test data)
  • faker (realistic fake data)
  • test_prof (for faster tests)

Code Quality

  • standard (Ruby linting—replaces Rubocop)
  • prettier (JavaScript formatting, if you have JS)

IDs & Slugs

  • nanoid (short, URL-safe IDs)
  • friendly_id (SEO-friendly slugs)

HTTP & API

  • httparty (clean, readable HTTP requests)
  • Don't use: Faraday, RestClient

Admin

  • avo (modern Rails admin panel, if you need it)

Error Tracking

  • sentry-rails (error reporting—optional)

Don't use:

  • Service objects (services/)
  • Context objects (contexts/)
  • Use case / operation / interactor gems
  • Custom authentication modules
  • Concerns for business logic
  • Devise
  • CanCanCan
  • ActiveAdmin
  • Complex state machine gems (use enums)
  • Virtus, Literal, dry-types, reform (use plain models)

File Structure & Organization

Folder Layout

app/
├── models/
│   ├── application_record.rb
│   ├── participant.rb
│   ├── cloud.rb
│   ├── cloud/
│   │   ├── card_generator.rb      # Namespaced under model
│   │   ├── nsfw_detector.rb
│   │   └── query.rb
│   ├── invitation.rb
│   ├── invitation/
│   │   └── mailer.rb
│   └── application_query.rb        # Base class for queries
├── controllers/
│   ├── application_controller.rb
│   ├── clouds_controller.rb
│   ├── participant/                # Namespace for scoped routes
│   │   ├── application_controller.rb
│   │   ├── clouds_controller.rb
│   │   └── homes_controller.rb
│   └── webhooks/
│       └── mandrill_controller.rb
├── jobs/
│   ├── application_job.rb
│   └── cloud_generation_job.rb
├── forms/                          # Only when creating multiple models
│   ├── application_form.rb
│   └── participant_registration_form.rb
├── mailers/
│   ├── application_mailer.rb
│   └── invitation_mailer.rb
├── policies/                       # Only when complex authorization is needed
│   ├── application_policy.rb
│   └── cloud_policy.rb
├── views/
│   ├── clouds/
│   ├── participant/
│   │   ├── clouds/
│   │   └── homes/
│   ├── layouts/
│   └── components/                 # ViewComponent components
│       └── cloud_card.html.erb
└── frontend/                       # If using Vite
    ├── entrypoints/
    ├── controllers/                # Stimulus controllers
    └── styles/

config/
├── configs/                        # anyway_config classes
│   ├── application_config.rb
│   ├── gemini_config.rb
│   ├── smtp_config.rb
│   └── app_config.rb
├── database.yml
├── routes.rb
└── puma.rb

db/
├── migrate/
├── seeds.rb
└── schema.rb

spec/
├── models/
├── requests/                       # Controller tests
├── system/                         # Full-stack browser tests
├── factories/
├── support/
└── spec_helper.rb

Critical rules:

  • No app/services/ folder
  • No app/contexts/ folder
  • No app/operations/ folder
  • Complex operations go in namespaced model classes: Cloud::CardGenerator
  • Controllers are namespaced for authentication/scoping: Participant::CloudsController

Model Patterns

Naming: Use Domain Language

Bad (Technical):

class User < ApplicationRecord
  has_many :generated_images
end

class GeneratedImage < ApplicationRecord
  belongs_to :user
end

Good (Domain-appropriate):

class Participant < ApplicationRecord
  has_many :clouds, dependent: :destroy
  has_many :invitations, dependent: :destroy
end

class Cloud < ApplicationRecord
  belongs_to :participant
end

class Invitation < ApplicationRecord
  belongs_to :participant
end

Names should reflect the business domain. "Participant" is what they are at a conference. "Cloud" is what they generate. Not generic terms.

Model Organization Order

Always follow this order in model files:

class Cloud < ApplicationRecord
  # 1. Gems and DSL extensions
  extend FriendlyId
  friendly_id :name, use: [:slugged, :finders]

  # 2. Associations
  belongs_to :participant
  has_many :invitations, dependent: :destroy
  has_one :latest_invitation, -> { order(created_at: :desc) }, class_name: "Invitation"

  # 3. Enums (for state)
  enum :state, %w[uploaded analyzing analyzed generating generated failed].index_by(&:itself)

  # 4. Normalization (Rails 8+)
  normalizes :name, with: ->(name) { name.strip }

  # 5. Validations
  validates :name, :state, presence: true
  validates :participant_id, presence: true

  # 6. Scopes
  scope :generated, -> { where(state: :generated) }
  scope :picked, -> { where(picked: true) }
  scope :recent, -> { order(created_at: :desc) }

  # 7. Callbacks
  before_create do
    self.state ||= :uploaded
  end

  # 8. Delegated methods
  delegate :email, to: :participant, prefix: true

  # 9. Public instance methods
  def ready_to_generate?
    analyzed? && !generating?
  end

  # 10. Private methods
  private

  def generate_filename
    "cloud-#{participant.slug}-#{id}.png"
  end
end

Use Enums for State

Always use enums for states. No string columns like status or state_string.

class Cloud < ApplicationRecord
  enum :state, %w[uploaded analyzing analyzed generating generated failed].index_by(&:itself)
end

# Usage:
cloud.uploaded?          # Predicate method
cloud.generating!        # Bang method (update + save)
Cloud.generated.count    # Scope

Why: Type-safe, gives you predicate methods for free, database-efficient.

Use normalizes for Data Cleanup

Rails 8 feature. Automatically clean data before validation.

class Participant < ApplicationRecord
  normalizes :email, with: ->(email) { email.strip.downcase }
end

# Before save:
participant = Participant.new(email: "  USER@EXAMPLE.COM  ")
participant.save
participant.email  # → "user@example.com"

Thin Models, Smart Organization

Model should not be 100+ lines. If it is, extract to namespaced classes.

Bad (Model too fat):

class Cloud < ApplicationRecord
  def generate_card_image
    # 50 lines of API logic
    # 20 lines of image processing
    # 30 lines of error handling
  end

  def check_nsfw
    # 40 lines of moderation logic
  end

  def upload_to_storage
    # 30 lines of storage logic
  end
end

Good (Extracted to namespaced classes):

class Cloud < ApplicationRecord
  # Model: just data and simple methods
  def ready_to_generate?
    analyzed?
  end
end

class Cloud::CardGenerator
  def initialize(cloud, api_key: GeminiConfig.api_key)
    @cloud = cloud
    @api_key = api_key
  end

  def generate
    # Complex API logic here, returns IO object
  end
end

class Cloud::NSFWDetector
  def initialize(cloud, api_key: GeminiConfig.api_key)
    @cloud = cloud
    @api_key = api_key
  end

  def check
    # Moderation logic, returns true/false
  end
end

When to extract:

  • Any method over 15 lines
  • Any method calling external APIs
  • Any complex calculation
  • Anything reusable

How to structure:

# app/models/cloud/card_generator.rb
class Cloud::CardGenerator
  private attr_reader :cloud, :api_key

  def initialize(cloud, api_key: GeminiConfig.api_key)
    @cloud = cloud
    @api_key = api_key
  end

  def generate
    # Public method that returns simple value or raises
    prompt = build_prompt
    response = call_api(prompt)
    decode_image(response)
  end

  private

  def build_prompt
    # ...
  end

  def call_api(prompt)
    # ...
  end

  def decode_image(response)
    # ...
  end
end

Use private attr_reader for internal state. Delegates to related objects. Returns simple values (IO, strings, booleans). Raises exceptions on error (don't return error objects).

Use Counter Caches

Every has_many should have a counter cache.

class Participant < ApplicationRecord
  has_many :clouds, dependent: :destroy
  has_many :invitations, dependent: :destroy
end

class Cloud < ApplicationRecord
  belongs_to :participant, counter_cache: true
end

class Invitation < ApplicationRecord
  belongs_to :participant, counter_cache: true
end

# No N+1 queries. Count is always up-to-date.
participant.clouds_count    # Fast, no query
participant.invitations_count

Callbacks: Use Sparingly

Callbacks are okay for simple things. Not for workflows.

Good use:

class Participant < ApplicationRecord
  before_create do
    self.access_token ||= Nanoid.generate(size: 6)
  end

  before_save do
    self.slug = nil if name_changed?  # Friendly ID will regenerate
  end
end

Bad use:

# DON'T: Complex workflow in callback
class Cloud < ApplicationRecord
  after_create do
    CloudGenerationJob.perform_later(self)
    Mailer.notify_created(self).deliver_later
    Metrics.record_cloud_created(self)
  end
end

Instead use: A job or form object for workflows.


Controller Patterns

Keep Controllers Extremely Thin

Target: 5-10 lines per action. No business logic.

class Participant::CloudsController < Participant::ApplicationController
  def new
    redirect_to home_path unless @participant.can_generate_cloud?
  end

  def create
    return head 422 unless @participant.can_generate_cloud?

    blob = ActiveStorage::Blob.find_signed(params[:cloud][:blob_signed_id])
    return head 422 unless blob

    cloud = @participant.clouds.create do
      it.image.attach(blob)
    end

    CloudGenerationJob.perform_later(cloud)

    redirect_to cloud_path(cloud)
  end

  def update
    cloud = @participant.clouds.find(params[:id])
    Cloud.transaction do
      @participant.clouds.update_all(picked: false)
      cloud.update_column(:picked, true)
    end

    redirect_to home_path
  end
end

Action breakdown:

  • Guard clauses (early returns)
  • Simple model operations (create, update)
  • Job enqueueing
  • Redirect/render

No:

  • Business logic
  • Complex conditionals
  • Multiple model operations (use Form Object instead)
  • Data transformation

Use Namespace Controllers for Authentication/Scoping

Pattern:

# app/controllers/participant/application_controller.rb
class Participant::ApplicationController < ::ApplicationController
  before_action :set_participant

  private

  def set_participant
    @participant = ::Participant.find_by!(access_token: params[:access_token])
  end
end

# app/controllers/participant/clouds_controller.rb
class Participant::CloudsController < Participant::ApplicationController
  # @participant is automatically set
  def index
    @clouds = @participant.clouds.recent
  end
end

All routes under Participant:: are automatically scoped. No need for concerns or custom modules.

Return Early, Use Guard Clauses

Bad:

def create
  if user.premium?
    if params[:name].present?
      if validate_input
        cloud = create_cloud
        return redirect_to cloud
      end
    end
  end
  head 422
end

Good:

def create
  return head 401 unless @participant.premium?
  return head 422 unless params[:name].present?
  return head 422 unless validate_input

  cloud = @participant.clouds.create!(params.permit(:name))
  redirect_to cloud
end

Guard clauses make the happy path obvious.

Don't Use Concerns for Business Logic

Bad:

module TokenAuthenticated
  extend ActiveSupport::Concern

  included do
    before_action :authenticate_by_token!
  end

  def authenticate_by_token!
    # ...
  end
end

class CloudsController < ApplicationController
  include TokenAuthenticated
end

Good:

class Participant::ApplicationController < ApplicationController
  before_action :set_participant

  private

  def set_participant
    @participant = Participant.find_by!(access_token: params[:access_token])
  end
end

class Participant::CloudsController < Participant::ApplicationController
  # Inheritance handles scoping, no magic
end

Inheritance is clearer than concerns.


Database Design & Migrations

Normalize Data: One Concern Per Table

Bad (Denormalized):

create_table :participants do |t|
  t.string :email
  t.string :full_name
  t.datetime :invitation_sent_at
  t.datetime :invitation_opened_at
  t.string :bounce_type
  t.datetime :bounced_at
  t.boolean :invitation_resend_requested
  # Everything crammed together
end

Good (Normalized):

create_table :participants do |t|
  t.string :email, null: false
  t.string :full_name, null: false
  t.string :access_token
  t.integer :cloud_generations_quota, default: 5
  t.integer :cloud_generations_count, default: 0
  t.integer :invitations_count, default: 0
  t.timestamps
end

create_table :invitations do |t|
  t.integer :participant_id, null: false, foreign_key: true
  t.enum :status, enum_type: :invitation_status, default: "sent"
  t.datetime :opened_at
  t.string :bounce_type
  t.datetime :bounced_at
  t.timestamps
end

create_table :clouds do |t|
  t.integer :participant_id, null: false, foreign_key: true
  t.enum :state, enum_type: :cloud_state, default: "uploaded"
  t.boolean :picked, default: false
  t.string :failure_reason
  t.timestamps
end

Each concern is a separate table. Easier to query, easier to extend, easier to analyze.

Use Foreign Keys & Constraints

create_table :clouds do |t|
  t.integer :participant_id, null: false
  t.foreign_key :participants, column: :participant_id, on_delete: :cascade
  t.enum :state, enum_type: :cloud_state, default: "uploaded", null: false
  t.timestamps
end

add_check_constraint :participants, "cloud_generations_count <= cloud_generations_quota"

Database enforces relationships and rules. Application bugs can't create invalid states.

Use Counter Caches

class CreateClouds < ActiveRecord::Migration[7.0]
  def change
    create_table :clouds do |t|
      t.integer :participant_id, null: false, foreign_key: true
      t.integer :participant_id
      t.foreign_key :participants, column: :participant_id
      t.timestamps
    end

    add_column :participants, :cloud_generations_count, :integer, default: 0, null: false
  end
end

Counter cache is a denormalization for performance. No N+1 queries.

Enums in the Database

Only in PostgreSQL

create_enum :cloud_state, ["uploaded", "analyzing", "analyzed", "generating", "generated", "failed"]
create_enum :invitation_status, ["sent", "opened", "bounced", "unsubscribed"]

create_table :clouds do |t|
  t.enum :state, enum_type: :cloud_state, default: "uploaded", null: false
end

create_table :invitations do |t|
  t.enum :status, enum_type: :invitation_status, default: "sent", null: false
end

Database-level enums prevent invalid states at the database layer.


Job Patterns

Use ActiveJob::Continuable for Multi-Step Workflows

Pattern:

# app/jobs/cloud_generation_job.rb
class CloudGenerationJob < ApplicationJob
  include ActiveJob::Continuable

  def perform(cloud)
    @cloud = cloud

    step :moderate, isolated: true
    step :generate, isolated: true unless cloud.failed?
  end

  private

  attr_reader :cloud

  def moderate(_step)
    cloud.update!(state: :analyzing)

    detector = Cloud::NSFWDetector.new(cloud)
    if detector.check
      cloud.update!(state: :analyzed)
    else
      cloud.update!(state: :failed, failure_reason: "NSFW content detected")
    end
  rescue => err
    Rails.error.report(err, handled: true)
    cloud.update!(state: :failed, failure_reason: err.message)
  end

  def generate(_step)
    cloud.update!(state: :generating)

    generator = Cloud::CardGenerator.new(cloud)
    io = generator.generate

    cloud.generated_image.attach(
      io:,
      filename: "cloud-#{cloud.participant.slug}.png",
      content_type: "image/png"
    )

    cloud.update!(state: :generated)

    Turbo::StreamsChannel.broadcast_refresh_to(cloud)
  rescue => err
    Rails.error.report(err, handled: true)
    cloud.update!(state: :failed, failure_reason: err.message)
  end
end

Why this pattern:

  • Each step is isolated (errors don't crash the whole job)
  • Conditional steps (skip generate if moderation fails)
  • Clear state transitions
  • Error handling is consistent
  • Progress visible to UI (via Turbo Streams)
  • Retryable steps

Key features:

  • include ActiveJob::Continuable
  • step :method_name, isolated: true defines each step
  • isolated: true means errors are caught and logged, job continues (or fails cleanly)
  • Update model state after each step
  • Broadcast progress for real-time updates

Jobs Orchestrate, Models Execute

Good separation:

# Job orchestrates workflow
class CloudGenerationJob < ApplicationJob
  def perform(cloud)
    step :generate
  end

  private

  def generate(_step)
    generator = Cloud::CardGenerator.new(cloud)
    io = generator.generate  # Delegates to model class
    cloud.generated_image.attach(io:, filename: "...")
  end
end

# Model class executes business logic
class Cloud::CardGenerator
  def initialize(cloud)
    @cloud = cloud
  end

  def generate
    # Complex API/processing logic here
    # Returns IO object or raises exception
    StringIO.new(decoded_image_data)
  end

  private

  def build_prompt
    # ...
  end

  def call_api(prompt)
    # ...
  end
end

Don't put complex logic in jobs. Jobs are for orchestration. Model classes handle complexity.

Error Handling in Jobs

def moderate(_step)
  # Do work
  cloud.update!(state: :analyzed)
rescue => err
  Rails.error.report(err, handled: true)  # Sends to Sentry if configured
  cloud.update!(state: :failed, failure_reason: err.message)
end

Always:

  • Catch errors with rescue => err
  • Report to error tracking (Rails.error.report)
  • Update model state to reflect failure
  • Don't re-raise unless you want the entire job to fail

Configuration Management

Use anyway_config for Type-Safe Configuration

Pattern:

# config/configs/application_config.rb
class ApplicationConfig < Anyway::Config
  class << self
    delegate_missing_to :instance

    private

    def instance
      @instance ||= new
    end
  end
end

# config/configs/gemini_config.rb
class GeminiConfig < ApplicationConfig
  attr_config :api_key
end

# config/configs/app_config.rb
class AppConfig < ApplicationConfig
  attr_config :host, :port,
    admin_username: "admin",
    admin_password: "pass"

  def ssl?
    port == 443
  end

  def asset_host
    super || begin
      proto = ssl? ? "https://" : "http://"
      "#{proto}#{host}"
    end
  end
end

Usage:

GeminiConfig.api_key
AppConfig.host
AppConfig.ssl?

Environment variables map automatically:

GEMINI_API_KEY=xxx          # → GeminiConfig.api_key
APP_HOST=example.com        # → AppConfig.host
APP_PORT=443                # → AppConfig.port

Why this approach:

  • Type-safe (validates on load)
  • Singleton pattern (access anywhere)
  • Organized in config/configs/
  • Can add helper methods (ssl?, configured?)
  • Environment-specific (development, test, production)

Never use Rails credentials or ENV directly

Bad:

api_key = ENV["GEMINI_API_KEY"]  # Error-prone, untyped

ENV.fetch("API_KEY", "default")  # Works, but no validation

Good:

api_key = GeminiConfig.api_key  # Type-safe, organized, testable

Form Objects

Use Form Objects for Multi-Model Operations

When:

  • Creating/updating multiple related records
  • Complex validations across models
  • Need transaction boundaries
  • Want to decouple from controller

Pattern:

# app/forms/application_form.rb
class ApplicationForm
  include ActiveModel::API
  include ActiveModel::Attributes
  include AfterCommitEverywhere

  define_callbacks :save, only: :after
  define_callbacks :commit, only: :after

  class << self
    def after_save(...)
      set_callback(:save, :after, ...)
    end

    def after_commit(...)
      set_callback(:commit, :after, ...)
    end

    def model_name
      @model_name ||= ActiveModel::Name.new(nil, nil, self.class.name.sub(/Form$/, ""))
    end
  end

  def save
    return false unless valid?

    with_transaction do
      after_commit { run_callbacks(:commit) }
      run_callbacks(:save) { submit! }
    end
  end

  private

  def with_transaction(&block)
    ApplicationRecord.transaction(&block)
  end

  def submit!
    raise NotImplementedError
  end
end

# app/forms/participant_registration_form.rb
class ParticipantRegistrationForm < ApplicationForm
  attribute :full_name, :string
  attribute :email, :string

  validates :full_name, :email, presence: true

  private

  def submit!
    participant = Participant.create!(
      full_name:,
      email:
    )

    invitation = participant.invitations.create!

    Mailer.send_invitation(invitation).deliver_later
  end
end

In controller:

def create
  @form = ParticipantRegistrationForm.new(form_params)

  if @form.save
    redirect_to home_path
  else
    render :new
  end
end

private

def form_params
  params.require(:participant_registration_form).permit(:full_name, :email)
end

Query Objects

Use Query Objects for Complex Queries

When:

  • Query has multiple conditions
  • Query is reused across controllers
  • Query is easier to test in isolation

Pattern:

# app/models/application_query.rb
class ApplicationQuery
  class << self
    attr_writer :query_model_name

    def query_model_name
      @query_model_name ||= name.sub(/::[^:]+$/, "")
    end

    def query_model
      query_model_name.safe_constantize
    end

    def call(...)
      new.call(...)
    end
  end

  private attr_reader :relation

  def initialize(relation = self.class.query_model.all)
    @relation = relation
  end

  def call
    relation
  end
end

# app/models/participant/pending_query.rb
class Participant::PendingQuery < ApplicationQuery
  def call
    relation
      .without_picked_cloud
      .where(blocked: false)
      .order(created_at: :desc)
  end
end

Usage:

# In controller
@pending = Participant::PendingQuery.call

# Or with a relation
@pending = Participant::PendingQuery.new(Participant.active).call

View & Frontend Patterns

Use Hotwire: Turbo + Stimulus

Don't build SPAs. Use Hotwire for interactivity.

Turbo for page updates:

<%= turbo_stream_from @cloud %>

<div id="<%= dom_id(@cloud) %>">
  <%= render "cloud", cloud: @cloud %>
</div>
# In job or controller
cloud.update!(state: :generated)
Turbo::StreamsChannel.broadcast_refresh_to(cloud)

Stimulus for JavaScript sprinkles:

<div data-controller="timer">
  <button data-action="timer#start">Start</button>
  <span data-timer-target="display">0:00</span>
</div>
// app/javascript/controllers/timer_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["display"]

  start() {
    // Timer logic
  }
}

Use ViewComponent for Reusable UI

# app/components/cloud_card_component.rb
class CloudCardComponent < ViewComponent::Base
  def initialize(cloud)
    @cloud = cloud
  end

  private

  attr_reader :cloud
end
<!-- app/components/cloud_card_component.html.erb -->
<div class="cloud-card" id="<%= dom_id(cloud) %>">
  <h3><%= cloud.name %></h3>
  <p><%= cloud.state %></p>
</div>

Usage in views:

<%= render CloudCardComponent.new(cloud) %>

Database Queries from Views

Good: Simple associations and scopes

<% @participant.clouds.recent.each do |cloud| %>
  <%= render "cloud", cloud: %>
<% end %>

Bad: N+1 queries, complex logic in views

<!-- DON'T: complex query logic -->
<% @clouds.select { |c| c.participant.premium? && c.state.in?(%w[generated]) } %>

Instead: Query object or scope

# Model
scope :recent, -> { order(created_at: :desc) }

# Controller
@clouds = @participant.clouds.recent

# View
<% @clouds.each do |cloud| %>
  <%= render "cloud", cloud: %>
<% end %>

Testing Patterns

RSpec > Minitest

Use RSpec for better DSL and readability.

Test Organization

spec/
├── models/          # Model logic
├── requests/        # Controller/HTTP responses
├── system/          # Full-stack browser tests (excluded by default)
├── factories/
├── support/
└── spec_helper.rb

Model Tests: Logic & Validations

describe Cloud do
  describe "#ready_to_generate?" do
    it "returns true when analyzed" do
      cloud = create(:cloud, state: :analyzed)
      expect(cloud.ready_to_generate?).to be true
    end

    it "returns false when generating" do
      cloud = create(:cloud, state: :generating)
      expect(cloud.ready_to_generate?).to be false
    end
  end

  describe "validations" do
    it "validates presence of participant_id" do
      cloud = build(:cloud, participant_id: nil)
      expect(cloud).not_to be_valid
    end
  end
end

Request Tests: HTTP Behavior

describe "Participant::CloudsController" do
  describe "POST /participant/:access_token/clouds" do
    it "creates a cloud when user can generate" do
      participant = create(:participant)
      blob = create(:active_storage_blob)

      expect do
        post participant_clouds_path(access_token: participant.access_token),
          params: { cloud: { blob_signed_id: blob.signed_id } }
      end.to change(Cloud, :count).by(1)

      expect(response).to redirect_to(participant_cloud_path(participant.access_token, Cloud.last))
    end
  end
end

System Tests: Critical User Flows

describe "Cloud generation flow", type: :system do
  it "generates a cloud from upload to display" do
    participant = create(:participant)

    visit participant_home_path(access_token: participant.access_token)
    click_button "Upload Image"

    # Upload interaction, assertions...
    expect(page).to have_content "Cloud generated"
  end
end

Tip: Exclude system tests by default in spec_helper.rb:

RSpec.configure do |config|
  config.filter_run_excluding type: :system
end

Run with: rspec --tag type:system

Use FactoryBot for Test Data

# spec/factories/participants.rb
FactoryBot.define do
  factory :participant do
    full_name { Faker::Name.name }
    email { Faker::Internet.email }

    trait :with_cloud do
      after(:create) do |participant|
        create(:cloud, participant:)
      end
    end
  end
end

# In tests
participant = create(:participant, :with_cloud)

Don't Test Framework Behavior

Skip these tests:

  • ActiveRecord callbacks (framework tests these)
  • Basic CRUD (framework tests these)
  • Model associations (too simple to break)
  • Generated code (don't test the generator output)

Test these:

  • Custom validations
  • Business logic methods
  • Controller responses
  • Integration flows

Routing

RESTful Routes with Namespaces

Rails.application.routes.draw do
  root "clouds#index"

  # Public routes
  resources :clouds, only: [:index, :show]

  # Participant-scoped routes
  scope "/c/:access_token", as: :participant, module: :participant do
    resource :home, only: [:show]
    resources :clouds
  end

  # Admin namespace
  mount Avo::Engine, at: Avo.configuration.root_path

  # Webhooks
  namespace :webhooks do
    resource :mandrill, only: [:create]
  end
end

Route patterns:

  • RESTful resources (index, show, create, update, delete)
  • Namespaces for logical grouping (admin, webhooks, participant)
  • Scopes for parameter injection (:access_token available to all routes)
  • Conditionally mount tools (dev-only, feature-flagged)

Common Patterns & Anti-Patterns

Pattern: Guard Clauses Over Nested Conditionals

Bad:

def create
  if admin?
    if params[:valid]
      if check_limit
        create_object
      end
    end
  end
end

Good:

def create
  return head 401 unless admin?
  return head 422 unless params[:valid]
  return head 429 unless check_limit
  create_object
end

Pattern: Delegation Over Inheritance for Small Helpers

Bad:

module EmailHelper
  def participant_email
    "#{@participant.slug}@example.com"
  end
end

class CloudsController < ApplicationController
  include EmailHelper
end

Good:

class CloudsController < ApplicationController
  def email_address
    @participant.participant_email  # Delegate to model
  end
end

class Participant
  def participant_email
    "#{slug}@example.com"
  end
end

Anti-Pattern: Service Objects

Don't create app/services/:

# Bad
class ImageGenerationService
  def initialize(cloud)
    @cloud = cloud
  end

  def call
    # Logic here
  end
end

Do this instead:

  • Model method: cloud.generate_image!
  • Namespaced class: Cloud::CardGenerator.new(cloud).generate
  • Job: CloudGenerationJob.perform_later(cloud)

Anti-Pattern: Result Objects

Don't use:

Result = Struct.new(:success?, :data, :error, keyword_init: true)

generator.call  # => Result.new(success?: true, data: io, error: nil)

Do this instead:

generator.generate  # => returns IO, raises on error

Return simple values. Raise exceptions. Let the caller decide.

Anti-Pattern: Passing Around Hashes

Bad:

def create_cloud(params)
  { state: "generating", id: cloud.id, user_id: cloud.participant_id }
end

result = create_cloud(name: "test")
puts result[:state]

Good:

def create_cloud(params)
  Participant.create!(**params)
end

cloud = create_cloud(name: "test")
puts cloud.state

Return objects, not hashes. Type-safe and IDE-friendly.


Performance & Optimization

Counter Caches: Use Them

class Participant < ApplicationRecord
  has_many :clouds
  has_many :invitations
end

class Cloud < ApplicationRecord
  belongs_to :participant, counter_cache: true
end

# No N+1
participant.clouds_count  # Single column read, no query

Scopes: Don't Use Complex Calculations

Bad:

scope :active, -> {
  where("clouds.created_at > ?", Time.current - 30.days)
    .where("clouds.state = ? OR (clouds.state = ? AND clouds.updated_at > ?)",
      "generated", "generating", Time.current - 1.hour)
}

Good:

scope :recent, -> { where("created_at > ?", 30.days.ago) }
scope :active, -> { where(state: [:generated, :generating]) }
scope :recently_active, -> { where("updated_at > ?", 1.hour.ago) }

# Compose scopes
Cloud.recent.active.recently_active

N+1 Prevention

Use eager loading:

# Bad: N+1 queries
Participant.all.each { |p| p.clouds.count }

# Good: 2 queries total
Participant.all.includes(:clouds)

Indexes

create_table :clouds do |t|
  t.integer :participant_id
  t.enum :state
  t.boolean :picked

  t.index [:participant_id, :state]  # Composite index
  t.index :picked
end

Index on:

  • Foreign keys
  • Frequently queried columns
  • Enum state columns
  • Columns in scopes

Development Workflow

Generators: What to Use

# Generate models with associations
rails generate model Cloud participant:references state:enum

# Generate controllers with actions
rails generate controller Participant::Clouds show create

# Generate jobs
rails generate job CloudGeneration

# Generate migrations
rails generate migration AddPickedToClouds picked:boolean

What NOT to Generate

Don't use Rails generators for:

  • Service objects (don't generate these)
  • Scaffolding (too much boilerplate)
  • Full CRUD (customize manually)

Running Tests

# All tests
rspec

# Specific model
rspec spec/models/cloud_spec.rb

# Exclude system tests (slow)
rspec --tag ~type:system

# Only system tests
rspec --tag type:system

# Verbose output
rspec -f d

Standard Ruby Linting

# Check style
bundle exec standardrb

# Fix automatically
bundle exec standardrb --fix

Deployment & Production Readiness

Environment Variables

# .env (development/test)
GEMINI_API_KEY=test-key
APP_HOST=localhost:3000
APP_PORT=3000

# .env.production
GEMINI_API_KEY=production-key
APP_HOST=sfruby.cloud
APP_PORT=443

Database Constraints

Always add to migrations:

add_null_constraint :clouds, :participant_id
add_check_constraint :participants, "cloud_generations_count <= cloud_generations_quota"
add_foreign_key :clouds, :participants, on_delete: :cascade

Database prevents invalid states.

Error Tracking

# Sentry (optional but recommended)
Sentry.init do |config|
  config.dsn = "https://xxxx@xxxx.ingest.sentry.io/xxxx"
  config.traces_sample_rate = 1.0
end

# In jobs/code
rescue => err
  Rails.error.report(err, handled: true)
end

Summary: The Checklist

Before writing/generating code, ask:

  • Is this named after a business domain concept (not technical)?
  • Is the model organized in the right order (gems → associations → enums → validations → scopes)?
  • Are states in enums, not string columns?
  • Is the controller action under 10 lines?
  • Is complex logic extracted to namespaced model classes (e.g., Cloud::CardGenerator)?
  • Is the database normalized (one concern per table)?
  • Are foreign keys and constraints added?
  • Are counter caches used for has_many?
  • Are workflows in jobs with ActiveJob::Continuable?
  • Is configuration via anyway_config, not ENV?
  • Are tests in RSpec, not minitest?
  • Are views simple (scopes/associations, no complex logic)?

If yes to all: your code is ready to ship.


Quick Reference

Pattern Location When
Model method Cloud#ready_to_generate? Simple query or check
Namespaced class Cloud::CardGenerator Complex operation (>15 lines)
Scope Cloud.generated Reusable query
Job CloudGenerationJob Async workflow or steps
Form Object ParticipantRegistrationForm Multi-model create/update
Query Object Participant::PendingQuery Complex query
Controller action Participant::CloudsController#create HTTP handling only
View component CloudCardComponent Reusable UI
Config class GeminiConfig Configuration
Anti-Pattern Why Alternative
Service object Unnecessary abstraction Namespaced model class
Result object Over-engineered Return value or raise
Concern for logic Magic, hard to trace Inheritance or delegation
ENV variables Untyped, scattered anyway_config
String state Type-unsafe Enum
Denormalized schema Harder to query/extend Normalized with FK + constraints
Fat models Hard to maintain Extract to namespaced classes
Fat controllers Hard to test Thin controller + model method
Complex conditionals Hard to read Guard clauses

Need Help?

This AGENTS.md is designed for AI code generation. If your AI-generated code doesn't match these patterns:

  1. Paste the AGENTS.md into your prompt when asking Claude/Copilot to generate Rails code
  2. Reference specific sections ("Follow the Controller Patterns section")
  3. Show examples of what you want (include a sample from sfruby-clouds if possible)

Questions? This guide is intentionally specific. The more constraints you give AI tools, the better code they generate.


Version: 1.0 Last Updated: December 2025 Created by: Evil Martians For: Rails developers, founders, designers building with code generation

@Hanaffi
Copy link

Hanaffi commented Dec 18, 2025

normalizes isn't a rails 8 feature. I used it before with rails 7

@palkan
Copy link
Author

palkan commented Dec 19, 2025

normalizes isn't a rails 8 feature. I used it before with rails 7

Yeah, it's from 7.2

@brkn
Copy link

brkn commented Dec 27, 2025

@wetterkrank
Copy link

hotwire-rails has been archived since 2021, perhaps require stimulus and turbo separately?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment