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.
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.
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)
Only use these gems. If you want to add something not listed, ask in a clarifying comment.
rails(latest stable)puma(default)propshaft(asset pipeline)
activerecord(included)pg(PostgreSQL)store_model(type-safe JSON columns, when needed)
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)
solid_queue(default)
- built-in authentication (generator)
omniauth(for OAuth)action_policy(for complex authorization)
anyway_config(type-safe configuration from environment)- Don't use: Rails credentials, ENV variables directly
rspec-rails(not minitest)factory_bot_rails(test data)faker(realistic fake data)test_prof(for faster tests)
standard(Ruby linting—replaces Rubocop)prettier(JavaScript formatting, if you have JS)
nanoid(short, URL-safe IDs)friendly_id(SEO-friendly slugs)
httparty(clean, readable HTTP requests)- Don't use: Faraday, RestClient
avo(modern Rails admin panel, if you need it)
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)
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
Bad (Technical):
class User < ApplicationRecord
has_many :generated_images
end
class GeneratedImage < ApplicationRecord
belongs_to :user
endGood (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
endNames should reflect the business domain. "Participant" is what they are at a conference. "Cloud" is what they generate. Not generic terms.
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
endAlways 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 # ScopeWhy: Type-safe, gives you predicate methods for free, database-efficient.
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"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
endGood (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
endWhen 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
endUse 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).
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_countCallbacks 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
endBad 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
endInstead use: A job or form object for workflows.
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
endAction 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
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
endAll routes under Participant:: are automatically scoped. No need for concerns or custom modules.
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
endGood:
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
endGuard clauses make the happy path obvious.
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
endGood:
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
endInheritance is clearer than concerns.
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
endGood (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
endEach concern is a separate table. Easier to query, easier to extend, easier to analyze.
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.
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
endCounter cache is a denormalization for performance. No N+1 queries.
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
endDatabase-level enums prevent invalid states at the database layer.
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
endWhy 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::Continuablestep :method_name, isolated: truedefines each stepisolated: truemeans errors are caught and logged, job continues (or fails cleanly)- Update model state after each step
- Broadcast progress for real-time updates
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
endDon't put complex logic in jobs. Jobs are for orchestration. Model classes handle complexity.
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)
endAlways:
- 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
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
endUsage:
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.portWhy 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)
Bad:
api_key = ENV["GEMINI_API_KEY"] # Error-prone, untyped
ENV.fetch("API_KEY", "default") # Works, but no validationGood:
api_key = GeminiConfig.api_key # Type-safe, organized, testableWhen:
- 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
endIn 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)
endWhen:
- 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
endUsage:
# In controller
@pending = Participant::PendingQuery.call
# Or with a relation
@pending = Participant::PendingQuery.new(Participant.active).callDon'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
}
}# 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) %>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 %>
Use RSpec for better DSL and readability.
spec/
├── models/ # Model logic
├── requests/ # Controller/HTTP responses
├── system/ # Full-stack browser tests (excluded by default)
├── factories/
├── support/
└── spec_helper.rb
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
enddescribe "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
enddescribe "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
endTip: Exclude system tests by default in spec_helper.rb:
RSpec.configure do |config|
config.filter_run_excluding type: :system
endRun with: rspec --tag type:system
# 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)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
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
endRoute 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)
Bad:
def create
if admin?
if params[:valid]
if check_limit
create_object
end
end
end
endGood:
def create
return head 401 unless admin?
return head 422 unless params[:valid]
return head 429 unless check_limit
create_object
endBad:
module EmailHelper
def participant_email
"#{@participant.slug}@example.com"
end
end
class CloudsController < ApplicationController
include EmailHelper
endGood:
class CloudsController < ApplicationController
def email_address
@participant.participant_email # Delegate to model
end
end
class Participant
def participant_email
"#{slug}@example.com"
end
endDon't create app/services/:
# Bad
class ImageGenerationService
def initialize(cloud)
@cloud = cloud
end
def call
# Logic here
end
endDo this instead:
- Model method:
cloud.generate_image! - Namespaced class:
Cloud::CardGenerator.new(cloud).generate - Job:
CloudGenerationJob.perform_later(cloud)
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 errorReturn simple values. Raise exceptions. Let the caller decide.
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.stateReturn objects, not hashes. Type-safe and IDE-friendly.
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 queryBad:
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_activeUse eager loading:
# Bad: N+1 queries
Participant.all.each { |p| p.clouds.count }
# Good: 2 queries total
Participant.all.includes(:clouds)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
endIndex on:
- Foreign keys
- Frequently queried columns
- Enum state columns
- Columns in scopes
# 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:booleanDon't use Rails generators for:
- Service objects (don't generate these)
- Scaffolding (too much boilerplate)
- Full CRUD (customize manually)
# 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# Check style
bundle exec standardrb
# Fix automatically
bundle exec standardrb --fix# .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=443Always 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: :cascadeDatabase prevents invalid states.
# 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)
endBefore 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.
| 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 |
This AGENTS.md is designed for AI code generation. If your AI-generated code doesn't match these patterns:
- Paste the AGENTS.md into your prompt when asking Claude/Copilot to generate Rails code
- Reference specific sections ("Follow the Controller Patterns section")
- 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
normalizes isn't a rails 8 feature. I used it before with rails 7