Compact guide for AI agents working on Rails applications. Focus on existing patterns, keep changes minimal, and preserve security/performance.
DO: Follow existing architecture (concerns, scopes, jobs, Turbo Streams, Stimulus). Keep diffs minimal. Optimize for clarity. Write tests for all new code. Preserve security/performance. Design deep modules.
DON'T: Introduce new frameworks. Mix refactors with behavior changes. Over-abstract. Bypass security checks. Log secrets. Create shallow wrappers or pass-through layers.
From John Ousterhout's A Philosophy of Software Design: The best modules are deep — they hide significant complexity behind a simple interface.
This is the central design principle for all code in this project. Every model, concern, service, controller, and Stimulus controller must justify its existence by providing a simple interface that hides meaningful implementation complexity.
Module Depth = (Complexity Hidden) / (Interface Exposed)
A deep module has a small, simple public API that hides substantial logic — parsing, validation, state management, external API calls, error handling, retries, caching.
A shallow module exposes an interface almost as complex as its implementation — it adds indirection without absorbing complexity. Do not create shallow modules.
# ✅ DEEP: Simple interface, complex implementation
class Competitor < ApplicationRecord
# Public API is just one method call.
# Internally: fetches HTML, parses structured data, normalizes pricing,
# handles rate limits/retries, updates 12 columns, timestamps the scrape.
def scrape_data
response = CompetitorScraper.fetch(url)
parsed = CompetitorParser.extract(response, source: scrape_source)
assign_attributes(
name: parsed.name,
pricing_tiers: parsed.pricing,
feature_list: parsed.features,
employee_count: parsed.team_size,
tech_stack: parsed.technologies,
last_scraped_at: Time.current
)
save!
end
end
# ❌ SHALLOW: Wrapper that just delegates — adds a layer without hiding anything
class CompetitorScrapingService
def initialize(competitor)
@competitor = competitor
end
def call
@competitor.scrape_data # Just forwards the call. Why does this class exist?
end
end
# ❌ SHALLOW: Method that does almost nothing
class Competitor < ApplicationRecord
def update_name(new_name)
update!(name: new_name) # This is just update! with a worse interface
end
endBefore creating a new class, module, concern, or service object, answer:
- What complexity does it hide? If the answer is "not much" or "it just delegates," don't create it.
- Is the interface simpler than the implementation? If the public API has as many concepts as the internals, the module is shallow.
- Does it have a reason to change independently? If it always changes in lockstep with another module, merge them.
- Can I name it with a specific noun/verb? Vague names (
Manager,Handler,Processor,Service) usually signal shallow design.
| Layer | Deep (✅) | Shallow (❌) |
|---|---|---|
| Model | competitor.scrape_data — hides HTTP, parsing, normalization, error handling |
competitor.set_name(n) — trivial wrapper around update! |
| Concern | Searchable — hides index management, query building, ranking |
Timestampable — just adds before_save { self.updated_at = Time.current } (Rails already does this) |
| Service | Onboarding::Complete — orchestrates workspace creation, competitor setup, email sequences across 3+ models |
MessageCreator — just calls room.messages.create!(params) |
| Job | Thin wrapper calling a deep model method (this is the correct use of a shallow layer — it's infrastructure glue) | Job containing all the business logic (inverts the depth) |
| Controller | Thin — delegates to deep models (controllers are supposed to be shallow; the depth lives in the model layer) | Fat controller with business logic that should be in models |
| Stimulus | FilterController — manages debounce, URL state, Turbo Frame updates, keyboard shortcuts |
ToggleController that only calls classList.toggle (acceptable only if truly reusable) |
When designing a module's public interface, hide:
- Implementation details — Callers shouldn't know how you scrape, parse, or sync
- Error handling & retries — Callers call one method; the module handles failures
- Data format transformations — Accept domain objects, return domain objects
- External service protocols — HTTP details, API pagination, auth tokens stay internal
- Performance optimizations — Caching, batch processing, connection pooling stay internal
- Temporal coupling — "Call A before B" should be enforced internally, not by the caller
# ✅ DEEP: Hides all the above
class Integration < ApplicationRecord
def sync
with_rate_limiting do
client = build_authenticated_client # hides auth protocol
data = client.fetch_all_pages # hides pagination
results = process_in_batches(data) # hides batching strategy
cache_results(results) # hides caching layer
update!(last_synced_at: Time.current)
end
rescue ExternalApi::RateLimitError => e
retry_after(e.retry_seconds) # hides retry logic
rescue ExternalApi::AuthError
mark_credentials_expired! # hides error recovery
end
end
# Usage — caller knows nothing about the above
integration.sync1. Pass-Through Methods
# ❌ Method that just calls another method with same signature
class UserService
def create_user(params)
User.create!(params)
end
end
# Just call User.create!(params) directly.2. Shallow Decorators
# ❌ Class that adds trivial formatting the view/helper should handle
class UserPresenter
def full_name
"#{@user.first_name} #{@user.last_name}"
end
end
# Put this on the model or in a helper. A whole class for string concatenation is shallow.3. Premature Extraction
# ❌ Concern extracted from a single model that only that model will ever use
module Competitor::NameNormalization
def normalize_name
self.name = name.strip.titleize
end
end
# Just put this in the model. Extract when a second model needs it.4. Needless Indirection Layers
# ❌ Repository pattern in Rails — ActiveRecord IS the repository
class UserRepository
def find(id) = User.find(id)
def all = User.all
def save(user) = user.save!
end
# This adds a layer that hides nothing. ActiveRecord already provides this interface.5. Config Objects for Simple Cases
# ❌ Over-engineered configuration
class ScrapingConfig
attr_reader :timeout, :retries
def initialize(timeout: 30, retries: 3)
@timeout = timeout
@retries = retries
end
end
# Just use keyword arguments or constants until complexity warrants a config object.Concerns should hide real complexity:
# ✅ DEEP concern — hides search indexing, query building, result ranking
module Searchable
extend ActiveSupport::Concern
included do
after_commit :reindex, if: :saved_change_to_searchable_content?
scope :search, ->(query) {
where("search_vector @@ plainto_tsquery(?)", query)
.order(Arel.sql("ts_rank(search_vector, plainto_tsquery(#{connection.quote(query)})) DESC"))
}
end
def reindex
update_column :search_vector, self.class.connection.execute(
"SELECT to_tsvector('english', #{self.class.connection.quote(searchable_text)})"
).first["to_tsvector"]
end
private
def searchable_text
raise NotImplementedError, "#{self.class} must implement #searchable_text"
end
def saved_change_to_searchable_content?
(saved_changes.keys & self.class.searchable_columns).any?
end
endService objects earn their existence through cross-model orchestration:
# ✅ DEEP service — coordinates 4 models, handles failures, ensures consistency
class Onboarding::Complete
def initialize(user)
@user = user
end
def call
ActiveRecord::Base.transaction do
workspace = create_default_workspace
seed_competitors(workspace)
configure_notification_preferences
schedule_initial_reports
end
deliver_welcome_sequence
@user.update!(onboarded_at: Time.current)
rescue => e
@user.update!(onboarding_failed_at: Time.current, onboarding_error: e.message)
raise
end
def self.call(user) = new(user).call
private
def create_default_workspace
@user.workspaces.create!(name: "#{@user.company_name}'s Workspace", plan: :trial)
end
def seed_competitors(workspace)
SuggestedCompetitors.for(@user.industry).each do |suggestion|
workspace.competitors.create!(name: suggestion.name, url: suggestion.url)
end
end
def configure_notification_preferences
@user.notification_preferences.create!(
weekly_digest: true,
competitor_alerts: true,
pricing_changes: true
)
end
def schedule_initial_reports
@user.workspaces.each { |w| w.schedule_first_report }
end
def deliver_welcome_sequence
OnboardingMailer.welcome(@user).deliver_later
OnboardingMailer.getting_started(@user).deliver_later(wait: 1.day)
end
endWhen making significant changes to architecture, database schema, or models, document the decision in changelog/.
- Database schema changes
- Model restructuring or renaming
- Major refactors affecting multiple files
- New architectural patterns introduced
Create a new file: changelog/YYYY-MM-DD-short-title.md
# Title
## Context
What is the background? What problem are we solving?
## Decision
What change was made?
## Consequences
- What are the implications?
- What migrations or follow-up work is needed?app/
├── models/
│ ├── user.rb # Main model
│ └── user/ # Concerns (each hides real complexity)
│ ├── role.rb # Permission logic, role transitions
│ └── avatar.rb # Upload, resize, CDN URL generation
├── controllers/
│ ├── concerns/ # Shared auth/setup logic
│ └── application_controller.rb
├── services/ # Only for cross-model orchestration
│ └── onboarding/
│ └── complete.rb
└── javascript/
└── controllers/ # Stimulus
# BAD: 500+ lines in user.rb
class User < ApplicationRecord
# Everything here
end
# GOOD: Split into concerns — each concern must hide meaningful complexity
class User < ApplicationRecord
include Avatar, Bot, Mentionable, Role
end# BAD: Concern that hides nothing
module User::Naming
extend ActiveSupport::Concern
def full_name = "#{first_name} #{last_name}"
end
# GOOD: Keep trivial methods on the model. Extract when complexity warrants it.
class User < ApplicationRecord
def full_name = "#{first_name} #{last_name}"
end# app/models/message.rb
class Message < ApplicationRecord
include Attachment, Broadcasts, Mentionee, Searchable
belongs_to :creator, class_name: "User", default: -> { Current.user }
end
# app/models/message/broadcasts.rb — DEEP: hides channel selection, targeting, partial rendering
module Message::Broadcasts
def broadcast_create
broadcast_append_to room, :messages, target: [ room, :messages ]
end
end# app/models/current.rb
class Current < ActiveSupport::CurrentAttributes
attribute :user, :account
end
# Usage
belongs_to :creator, class_name: "User", default: -> { Current.user }# ✅ DEEP: Simple call site, complex transactional logic hidden inside
has_many :memberships do
def revise(granted: [], revoked: [])
transaction do
revoke_from(revoked) if revoked.any?
grant_to(granted) if granted.any?
end
end
end
# Caller just writes:
room.memberships.revise(granted: [user_a], revoked: [user_b])class Room < ApplicationRecord; end
class Rooms::Open < Room; end
class Rooms::Closed < Room; endUse STI when: Types share 90%+ behavior, similar schema, need polymorphic queries.
Core Principle: All business logic should be executable directly from rails console. Background jobs, service objects, and controllers are thin wrappers that delegate to deep model methods.
- Fast feedback — Test logic instantly without triggering jobs or HTTP requests
- Debuggable — Reproduce issues with
User.find(123).sync_competitors - Composable — Combine operations in console for data fixes or exploration
- Testable — Unit test the logic, integration test the wrapper
- Deep by default — The model method is the deep module; the job is the thin interface layer
Every async operation should have a synchronous counterpart on the model. The sync method is deep (hides all complexity). The async method is intentionally shallow (infrastructure glue — the one acceptable shallow layer).
# app/models/competitor.rb
class Competitor < ApplicationRecord
# ✅ DEEP: synchronous method — contains ALL the logic
# Hides: HTTP fetching, HTML parsing, data normalization,
# error handling, attribute mapping, timestamping
def scrape_data
response = CompetitorScraper.fetch(url)
update!(
name: response.name,
pricing: response.pricing,
last_scraped_at: Time.current
)
end
# ✅ SHALLOW (acceptable): Async wrapper — just enqueues the job
def scrape_data_async
Competitor::ScrapeDataJob.perform_later(self)
end
end
# app/jobs/competitor/scrape_data_job.rb
class Competitor::ScrapeDataJob < ApplicationJob
# ✅ SHALLOW (acceptable): Job is infrastructure glue — NO business logic here
def perform(competitor)
competitor.scrape_data
end
endUsage:
# Console/tests - immediate execution
competitor.scrape_data
# Production code - background execution
competitor.scrape_data_async
# Bulk operations in console
Competitor.stale.find_each(&:scrape_data)| ❌ BAD: Logic in Job (Inverted Depth) | ✅ GOOD: Logic in Model (Correct Depth) |
|---|---|
| Can't test without job infrastructure | Test directly: model.do_thing |
| Can't run from console easily | Run anytime: Model.find(1).do_thing |
| Hard to debug production issues | Reproduce instantly in console |
| Logic scattered across layers | Single source of truth |
| Depth is in the wrong layer | Depth is in the model where it belongs |
Background Jobs:
class User < ApplicationRecord
def send_welcome_email
WelcomeMailer.welcome(self).deliver_now
end
def send_welcome_email_async
User::SendWelcomeEmailJob.perform_later(self)
end
endScheduled Tasks:
class Account < ApplicationRecord
# The actual work — DEEP method
def generate_weekly_report
Report.create!(
account: self,
data: calculate_metrics,
period: 1.week.ago..Time.current
)
end
# Class method for scheduler to call
def self.generate_all_weekly_reports
find_each(&:generate_weekly_report)
end
def self.generate_all_weekly_reports_async
Account::GenerateWeeklyReportsJob.perform_later
end
endExternal API Syncs:
class Integration < ApplicationRecord
# ✅ DEEP: Simple interface, hides client construction, pagination,
# data processing, error handling, timestamping
def sync
client = build_client
data = client.fetch_all
process_sync_data(data)
update!(last_synced_at: Time.current)
end
def sync_async
Integration::SyncJob.perform_later(self)
end
private
def process_sync_data(data)
# All the complex logic lives here, testable directly
end
endCreate a service object only when logic spans multiple models and the orchestration itself is complex. If a service just delegates to one model method, delete it and call the model directly.
# app/services/onboarding/complete.rb
# ✅ DEEP: Orchestrates 4+ models, handles failures, ensures consistency
class Onboarding::Complete
def initialize(user)
@user = user
end
# ✅ Main logic — callable directly
def call
ActiveRecord::Base.transaction do
create_default_workspace
setup_initial_competitors
send_welcome_sequence
end
@user.update!(onboarded_at: Time.current)
end
# ✅ Async wrapper
def call_async
Onboarding::CompleteJob.perform_later(@user)
end
# ✅ Convenience class methods
def self.call(user) = new(user).call
def self.call_async(user) = new(user).call_async
private
def create_default_workspace
@user.workspaces.create!(name: "My Workspace")
end
# ... other private methods with real logic
end
# Usage from anywhere:
Onboarding::Complete.call(user) # Sync
Onboarding::Complete.call_async(user) # Async
Onboarding::Complete.new(user).call # Instance style# app/models/concerns/async_scraping.rb
# ✅ DEEP concern: hides staleness logic, retry scheduling, job dispatch
module AsyncScraping
extend ActiveSupport::Concern
# Override in model to define scraping logic
def scrape
raise NotImplementedError, "#{self.class} must implement #scrape"
end
def scrape_async
ScrapeJob.perform_later(self.class.name, id)
end
def scrape_if_stale
scrape if stale?
end
def stale?
last_scraped_at.nil? || last_scraped_at < 24.hours.ago
end
end
# app/models/competitor.rb
class Competitor < ApplicationRecord
include AsyncScraping
def scrape
# Competitor-specific scraping logic — the deep part
end
end# Test the logic directly (fast, no job infrastructure)
test "scraping updates competitor data" do
competitor = competitors(:acme)
competitor.scrape_data
assert_not_nil competitor.reload.last_scraped_at
assert_equal "Acme Corp", competitor.name
end
# Test that async version enqueues correctly (integration)
test "scrape_data_async enqueues job" do
competitor = competitors(:acme)
assert_enqueued_with(job: Competitor::ScrapeDataJob, args: [competitor]) do
competitor.scrape_data_async
end
endBefore shipping, verify you can:
# ✅ Execute the core logic (deep methods, simple calls)
user.send_welcome_email
competitor.scrape_data
account.generate_weekly_report
# ✅ Check state without side effects
user.onboarded?
competitor.stale?
account.reports.last
# ✅ Bulk operations
Competitor.stale.find_each(&:scrape_data)
User.where(onboarded_at: nil).find_each { |u| Onboarding::Complete.call(u) }| Type | Pattern | Example |
|---|---|---|
| Models | Singular noun | User, Message |
| Namespaced | Rooms::Open, Push::Subscription |
|
| Concerns | Message::Broadcasts, User::Role |
|
| Controllers | Plural resource | MessagesController |
| Nested | accounts/bots_controller.rb |
|
| Services | Verb or verb phrase | Onboarding::Complete, Report::Generate |
| Stimulus | Kebab-case | auto_submit_controller.js |
| Tests | _test.rb suffix |
message_test.rb |
Naming red flags (usually signal shallow modules): Manager, Handler, Processor, Helper, Utils, Base, Wrapper. If you reach for these names, reconsider whether the abstraction is earning its keep.
test "creating a message enqueues push job" do
assert_enqueued_jobs 1, only: [ Room::PushMessageJob ] do
create_new_message_in rooms(:designers)
end
end
test "non-admin can't update another user's message" do
sign_in :jz
put room_message_url(room, message), params: { message: { body: "Updated" } }
assert_response :forbidden
end# test/fixtures/users.yml
david:
email_address: david@example.test
password_digest: <%= BCrypt::Password.create("secret123456") %>
role: administrator
# Usage
test "admin can delete message" do
sign_in :david
delete room_message_url(room, message)
assert_response :success
end# Test broadcasts
test "creating message broadcasts unread room" do
assert_broadcasts "unread_rooms", 1 do
post room_messages_url(@room, format: :turbo_stream), params: { message: { body: "New" } }
end
end
# Test jobs
test "mentioning bot triggers webhook" do
assert_enqueued_jobs 1, only: Bot::WebhookJob do
post room_messages_url(@room), params: { message: { body: mention } }
end
endEvery new feature or change MUST include tests:
| Change Type | Required Tests |
|---|---|
| New model | Model test with validations, associations, scopes |
| New controller action | Controller/integration test |
| New UI/view | System test with Capybara/Playwright |
| Bug fix | Regression test proving the fix |
| New job | Job test with assertions |
Commands:
bin/rails test # Run unit/integration tests
bin/rails test:system # Run system tests (Playwright)
bin/rails test test/models/user_test.rb # Run specific test fileSystem test example (Capybara + Playwright):
# test/system/homepage_test.rb
require "application_system_test_case"
class HomepageTest < ApplicationSystemTestCase
test "visiting the homepage" do
visit root_path
assert_text "Welcome"
end
end# app/controllers/concerns/authorization.rb
module Authorization
def ensure_owns_resource(resource)
head :forbidden unless resource.creator == Current.user || Current.user&.administrator?
end
end
# Usage
before_action -> { ensure_owns_resource(@message) }, only: [:update, :destroy]def message_params
params.require(:message).permit(:body, :client_message_id)
end
# NEVER: params[:message] # Mass assignment vulnerability# config/initializers/filter_parameter_logging.rb
Rails.application.config.filter_parameters += [
:passw, :email, :secret, :token, :_key, :crypt, :salt, :otp, :ssn
]validates :email_address, presence: true, uniqueness: true,
format: { with: URI::MailTo::EMAIL_REGEXP }
validates :role, inclusion: { in: %w[member administrator bot] }| ✅ DO | ❌ DON'T |
|---|---|
| Use concerns that hide real complexity | Extract trivially (shallow concerns) |
| Define scopes for common queries | Write raw SQL everywhere |
| Keep depth in models, not jobs | Put logic in jobs or controllers |
| Use callbacks sparingly | Abuse callbacks |
| Validate at model level | Assume data is valid |
Use default: for automatic values |
Set defaults in migrations only |
| Design simple public APIs | Expose implementation details |
# ✅ GOOD — Deep model: simple public API, complexity hidden in concerns
class Message < ApplicationRecord
include Broadcasts, Mentionee, Searchable
belongs_to :creator, class_name: "User", default: -> { Current.user }
scope :ordered, -> { order(:created_at) }
scope :recent, -> { ordered.limit(50) }
end| ✅ DO | ❌ DON'T |
|---|---|
| Keep controllers thin (shallow is correct here) | Put business logic in controllers |
| Use before_action for setup | Repeat setup in every action |
| Delegate to deep model methods | Orchestrate multiple steps inline |
| Return appropriate status codes | Always return 200 OK |
| Use concerns for shared logic | Copy/paste between controllers |
# ✅ GOOD — Controller is intentionally shallow; depth is in the model
class MessagesController < ApplicationController
before_action :set_room
before_action :set_message, only: [:show, :update, :destroy]
def create
@message = @room.messages.create!(message_params)
respond_to { |format| format.turbo_stream }
end
private
def message_params
params.require(:message).permit(:body, :client_message_id)
end
end| ✅ DO | ❌ DON'T |
|---|---|
| Keep controllers single-purpose | God controllers |
| Hide complexity behind simple connect/action API | Expose internals to the DOM |
| Use data attributes for config | Hardcode values |
| Clean up on disconnect | Cause memory leaks |
// ✅ GOOD — Deep Stimulus controller: simple data API, complex internal behavior
export default class extends Controller {
static values = { url: String, interval: { type: Number, default: 5000 } };
static targets = ["input", "output"];
connect() {
this.intervalId = setInterval(() => this.refresh(), this.intervalValue);
}
disconnect() {
clearInterval(this.intervalId);
}
}resources :rooms do
resources :messages, only: [:index, :create, :show, :update, :destroy]
endenum :role, %i[ member administrator bot ], prefix: :role
# Generates: role_member?, role_administrator?, role_bot?# ✅ GOOD
scope :with_creator, -> { preload(creator: :avatar_attachment) }
messages = room.messages.with_creator
# ❌ BAD
messages.each { |m| puts m.creator.name } # N queries!# app/jobs/room/push_message_job.rb
class Room::PushMessageJob < ApplicationJob
def perform(message)
message.room.push_notification_for(message)
end
end
# Trigger — the deep logic lives in push_notification_for
after_create_commit -> { Room::PushMessageJob.perform_later(self) }def create
@message = @room.messages.create!(message_params)
respond_to { |format| format.turbo_stream }
end
def broadcast_create
broadcast_append_to room, :messages, target: [ room, :messages ]
endBefore creating CSS, check the existing structure:
- Find the main stylesheet entry point (e.g.,
application.css) - Look for existing component files in a
components/directory - Check for theme/variable definitions
Rules:
- Import new component files in the main entry point
- One component per file in
components/ - Keep theme variables in a dedicated file (e.g.,
themes.css)
Always use existing CSS custom properties instead of hardcoded values:
/* ✅ GOOD - uses project's semantic variables */
@apply bg-primary text-primary-foreground;
@apply border-destructive;
/* ❌ BAD - hardcoded colors bypass theming */
@apply bg-blue-500 text-white;
@apply border-red-500;Before writing CSS:
- Check existing theme variables in the project
- Use semantic names (primary, secondary, accent, destructive, muted, etc.)
- Follow the existing color naming convention
Follow this pattern for new component stylesheets:
/* Component Name
* Usage: <element class="component" data-variant="x" data-size="y">
*
* Variants: list, available, options
* Sizes: sm, md, lg
* States: error, success, loading
*/
/* ==================== BASE ==================== */
.component {
@apply /* base styles */;
@apply transition-all duration-150 ease-in-out;
@apply focus-visible:ring-2 outline-none;
@apply disabled:pointer-events-none disabled:opacity-50;
}
/* ==================== SIZES ==================== */
.component[data-size="sm"] { @apply /* small */; }
.component[data-size="md"],
.component:not([data-size]) { @apply /* medium - default */; }
.component[data-size="lg"] { @apply /* large */; }
/* ==================== VARIANTS ==================== */
.component[data-variant="primary"],
.component:not([data-variant]) { @apply /* primary - default */; }
.component[data-variant="secondary"] { @apply /* secondary */; }
/* ==================== STATES ==================== */
.component[data-state="error"] { @apply /* error state */; }
.component[data-state="loading"] { @apply cursor-wait opacity-75; }
/* ==================== RESPONSIVE ==================== */
@media (max-width: 640px) {
.component { @apply /* mobile adjustments */; }
}Use data attributes instead of modifier classes:
<!-- ✅ GOOD - data attributes -->
<button class="btn" data-variant="accent" data-size="lg">Submit</button>
<input class="input" data-size="sm" data-state="error" />
<!-- ❌ BAD - BEM-style modifier classes -->
<button class="btn btn-accent btn-lg">Submit</button>
<input class="input input-sm input-error" />Standard data attributes:
| Attribute | Purpose | Examples |
|---|---|---|
data-variant |
Visual style | primary, secondary, accent, destructive, outline, ghost |
data-size |
Dimensions | sm, md, lg, xl |
data-state |
Current state | error, success, loading, disabled |
data-type |
Special type | icon (for icon-only buttons) |
Always provide sensible defaults so the base class works without attributes:
/* Default size when data-size is not specified */
.component[data-size="md"],
.component:not([data-size]) {
@apply h-10 px-4 py-2;
}
/* Default variant when data-variant is not specified */
.component[data-variant="primary"],
.component:not([data-variant]) {
@apply bg-primary text-primary-foreground;
}Every interactive component MUST include these states:
.component {
/* Focus visible for keyboard navigation */
@apply focus-visible:ring-2 focus-visible:outline-none;
/* Disabled state */
@apply disabled:pointer-events-none disabled:opacity-50;
/* Smooth transitions */
@apply transition-all duration-150 ease-in-out;
}| ✅ DO | ❌ DON'T |
|---|---|
| Use existing theme variables | Use raw color values (bg-blue-500) |
| Use data attributes for variants | Create BEM modifier classes (.btn--large) |
| Check existing components first | Duplicate similar styles |
| Include focus/disabled states | Skip accessibility states |
| Put styles in component files | Inline styles in views |
Use @apply for Tailwind utilities |
Mix raw CSS with Tailwind inconsistently |
| Add usage comments at file top | Leave components undocumented |
- Search for existing components - Check if a similar component exists
- Review theme variables - Use semantic colors from the theme
- Follow existing patterns - Match the style of existing component files
- Document usage - Add a comment block showing how to use the component
# config/database.yml
production:
database: storage/<%= ENV.fetch("RAILS_ENV") %>.sqlite3
# config/environments/production.rb
config.log_level = ENV.fetch("LOG_LEVEL", "info")Never hardcode: API keys, passwords, hosts/URLs, feature flags.
# .rubocop.yml
inherit_gem: { rubocop-rails-omakase: rubocop.yml }Run: bin/brakeman for security scanning.
- Design deep modules — Every abstraction must hide meaningful complexity behind a simple interface
- Reject shallow wrappers — No pass-through methods, trivial decorators, or needless indirection
- Depth belongs in models — Controllers, jobs, and views are intentionally thin; models and concerns are deep
- Organize by concern — Use modules/concerns that each absorb real complexity
- Keep files focused — Single responsibility, but not single-method (that's too shallow)
- Console-first design — All logic testable from
rails console _asyncpattern — Deep sync method + shallow async wrapper for every background operation- Test security — Authorization, edge cases
- Security by default — Auth, validation, CSRF
- Follow conventions — Use framework patterns
- Avoid N+1 — Preload associations, use background jobs
- Clean up resources — Timers, listeners, subscriptions
- CSS: Theme variables — Use semantic CSS custom properties, not hardcoded colors
- CSS: Data attributes — Configure components via
data-*attributes, not modifier classes
Before creating any new class, module, or method, ask:
"Does this hide more complexity than it introduces?"
If yes → create it. If no → inline it, merge it, or delete it.