Skip to content

Instantly share code, notes, and snippets.

@adham90
Created February 28, 2026 00:02
Show Gist options
  • Select an option

  • Save adham90/6116bc72409c562665c20ead8515b7cc to your computer and use it in GitHub Desktop.

Select an option

Save adham90/6116bc72409c562665c20ead8515b7cc to your computer and use it in GitHub Desktop.
Claude_v2.md

AI Coding Agent Guide: Rails Best Practices

Compact guide for AI agents working on Rails applications. Focus on existing patterns, keep changes minimal, and preserve security/performance.

Core Principles

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.


Deep Modules Philosophy

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.

The Rule

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 vs. Shallow in Rails

# ✅ 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
end

Decision Framework: When to Create a New Abstraction

Before creating a new class, module, concern, or service object, answer:

  1. What complexity does it hide? If the answer is "not much" or "it just delegates," don't create it.
  2. Is the interface simpler than the implementation? If the public API has as many concepts as the internals, the module is shallow.
  3. Does it have a reason to change independently? If it always changes in lockstep with another module, merge them.
  4. Can I name it with a specific noun/verb? Vague names (Manager, Handler, Processor, Service) usually signal shallow design.

Deep Module Patterns in Rails

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)

Information Hiding Checklist

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.sync

Anti-Patterns to Reject

1. 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.

Applying Deep Modules to Existing Patterns

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
end

Service 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
end

Change Log

When making significant changes to architecture, database schema, or models, document the decision in changelog/.

What to Log

  • Database schema changes
  • Model restructuring or renaming
  • Major refactors affecting multiple files
  • New architectural patterns introduced

ADR Format

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?

Project Organization

✅ DO: Use Clear Directory Structure

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

❌ DON'T: Mix Concerns in One File

# 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

❌ DON'T: Extract Trivially

# 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

Code Architecture

✅ DO: Use Concerns for Shared Behavior (That Hide Real Complexity)

# 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

✅ DO: Use Current for Thread-Safe Context

# app/models/current.rb
class Current < ActiveSupport::CurrentAttributes
  attribute :user, :account
end

# Usage
belongs_to :creator, class_name: "User", default: -> { Current.user }

✅ DO: Extend Associations (Deep Interface on the Collection)

# ✅ 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])

✅ DO: Use STI When Appropriate

class Room < ApplicationRecord; end
class Rooms::Open < Room; end
class Rooms::Closed < Room; end

Use STI when: Types share 90%+ behavior, similar schema, need polymorphic queries.


Console-First Testability

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.

Why This Matters

  • 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

The _async Pattern

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
end

Usage:

# 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)

Pattern Comparison

❌ 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

Apply to All Async Operations

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
end

Scheduled 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
end

External 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
end

Service Objects (Only When Depth Warrants It)

Create 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

Concerns for Shared Async Patterns

# 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

Testing Async Operations

# 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
end

Console Debugging Checklist

Before 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) }

Naming Conventions

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.


Testing

✅ DO: Descriptive Test Names

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

✅ DO: Use Fixtures

# 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

✅ DO: Test Security & Jobs

# 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
end

✅ REQUIRED: Test All New Code

Every 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 file

System 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

Security

✅ DO: Authentication & Authorization

# 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]

✅ DO: Strong Parameters

def message_params
  params.require(:message).permit(:body, :client_message_id)
end
# NEVER: params[:message]  # Mass assignment vulnerability

✅ DO: Filter Sensitive Data

# config/initializers/filter_parameter_logging.rb
Rails.application.config.filter_parameters += [
  :passw, :email, :secret, :token, :_key, :crypt, :salt, :otp, :ssn
]

✅ DO: Validate Input

validates :email_address, presence: true, uniqueness: true,
                          format: { with: URI::MailTo::EMAIL_REGEXP }
validates :role, inclusion: { in: %w[member administrator bot] }

Do's and Don'ts

Models

✅ 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

Controllers

✅ 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

JavaScript/Stimulus

✅ 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);
  }
}

Rails-Specific Tips

✅ DO: RESTful Routes

resources :rooms do
  resources :messages, only: [:index, :create, :show, :update, :destroy]
end

✅ DO: Enums with Prefix

enum :role, %i[ member administrator bot ], prefix: :role
# Generates: role_member?, role_administrator?, role_bot?

✅ DO: Preload to Avoid N+1

# ✅ GOOD
scope :with_creator, -> { preload(creator: :avatar_attachment) }
messages = room.messages.with_creator

# ❌ BAD
messages.each { |m| puts m.creator.name }  # N queries!

✅ DO: Background Jobs (Thin Wrappers for Deep Model Methods)

# 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) }

✅ DO: Turbo Streams

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 ]
end

CSS & Tailwind Guidelines

File Organization

Before creating CSS, check the existing structure:

  1. Find the main stylesheet entry point (e.g., application.css)
  2. Look for existing component files in a components/ directory
  3. 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)

Theme Variables

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:

  1. Check existing theme variables in the project
  2. Use semantic names (primary, secondary, accent, destructive, muted, etc.)
  3. Follow the existing color naming convention

Component File Structure

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 */; }
}

Data Attributes for Configuration

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)

Default Values with :not() Selectors

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;
}

Required Accessibility States

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;
}

CSS Do's and Don'ts

✅ 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

Before Writing Any CSS

  1. Search for existing components - Check if a similar component exists
  2. Review theme variables - Use semantic colors from the theme
  3. Follow existing patterns - Match the style of existing component files
  4. Document usage - Add a comment block showing how to use the component

Configuration

✅ DO: Environment Variables

# 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.

✅ DO: Linters & Security

# .rubocop.yml
inherit_gem: { rubocop-rails-omakase: rubocop.yml }

Run: bin/brakeman for security scanning.


Key Takeaways

  1. Design deep modules — Every abstraction must hide meaningful complexity behind a simple interface
  2. Reject shallow wrappers — No pass-through methods, trivial decorators, or needless indirection
  3. Depth belongs in models — Controllers, jobs, and views are intentionally thin; models and concerns are deep
  4. Organize by concern — Use modules/concerns that each absorb real complexity
  5. Keep files focused — Single responsibility, but not single-method (that's too shallow)
  6. Console-first design — All logic testable from rails console
  7. _async pattern — Deep sync method + shallow async wrapper for every background operation
  8. Test security — Authorization, edge cases
  9. Security by default — Auth, validation, CSRF
  10. Follow conventions — Use framework patterns
  11. Avoid N+1 — Preload associations, use background jobs
  12. Clean up resources — Timers, listeners, subscriptions
  13. CSS: Theme variables — Use semantic CSS custom properties, not hardcoded colors
  14. CSS: Data attributes — Configure components via data-* attributes, not modifier classes

The Deep Modules Litmus Test

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.

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